JWT(Json Web Token) : 서버가 로그인 상태를 저장하지 않고, JWT 자체에 모든 인증 정보가 담겨있는 인증 방식
동작 흐름
build.gradle에 의존성 추가
implementation "io.jsonwebtoken:jjwt-api:0.12.5"
runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.5"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.5"
JwtUtil 클래스 생성 : init(), generateToken(), get(), validateToken() 메서드 생성
jwt:
secret:
key: HiMyNameIsBellaNowImStudyingJwtItsNotReallyHardButItsKindOfComplicated
// Bean으로 등록
@Component
public class JwtUtil {
// 암호화 키(yml 파일에서 불러오기) : 그냥 사용해도 상관없지만, 유지보수를 위해 yml 파일에서 생성하는 것이 좋음
@Value("${jwt.secret.key}")
private String secretKeyString;
// JWT 라이브러리에서 사용하는 객체
private SecretKey key; // secretKey 문자열을 비밀키로 만들어 넣어주는 객체 -> 암호화, 복호화할 때 사용
private JwtParser parser; // 복호화할 때 사용하는 객체
private final long TOKEN_TIME = 60 * 60 * 1000L; // JWT 토큰의 만료 시간(밀리초 단위, 여기서는 60분)
// 내가 지정한 문자열을 JWT에서 사용하는 객체로 변환
@PostConstruct // 서버를 실행할 때 가장 먼저 실행되어야 하기 때문에 Bean 중에 가장 먼저 실행할 수 있도록 어노테이션 추가
public void init() {
// 문자열인 시크릿키를 비트 단위로 분해
byte[] bytes = Decoders.BASE64.decode(secretKeyString);
// 암호화를 시킨 뒤에 JWT에서 사용할 객체에 저장 -> JWT에서 제공한 암호화 로직을 거쳐 한 번 더 보안을 강화하는 것
this.key = Keys.hmacShaKeyFor(bytes);
// 암호화된 키를 사용해서 parser 객체를 만들어주는 것
this.parser = Jwts.parser().verifyWith(this.key).build();
}
// 토큰 생성
public String generateToken(long userId) {
Date date = new Date();
return Jwts.builder()
.claim("userId", userId) // 토큰에 들어갈 payload -> 유저ID넣어주기
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 설정
.setIssuedAt(date) // 발급 시간 설정
.signWith(this.key, Jwts.SIG.HS256) // 토큰에 들어갈 header(signature) 암호화 -> key를 사용하고, HS256 암호화 방식을 사용
.compact();
}
// 토큰 안에 있는 정보 가져오기
public Claims getAllData(String jwt) {
// 암호화된 토큰을 풀어주기 -> jwt 토큰 중에 Payload 값을 가져오기
return parser.parseSignedClaims(jwt).getPayload();
}
// 토큰 안의 유저ID만 가져오기
public String getUserId(String jwt) {
return parser.parseSignedClaims(jwt).getPayload().get("userId", String.class);
}
}
로그인 시 토큰 생성하기
// Controller
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request, HttpSession session) {
// Service에서 생성된 토큰을 반환
return ResponseEntity.ok(userService.login(request));
}
// Service
@Transactional(readOnly = true)
public String login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail()).orElseThrow(() -> new CustomException(NOT_VALID_LOGIN));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new CustomException(NOT_VALID_LOGIN);
}
// generateToken을 통해 토큰 생성 후 반환
return jwtUtil.generateToken(user.getId());
}
JwtUtil 클래스 : 토큰의 유효성을 확인하는 validateToken() 메서드 생성
// Bean으로 등록
@Component
public class JwtUtil {
...
// 토큰의 유효성 확인
public boolean validateToken(String jwt) {
// 토큰이 비어있거나 공백인지 확인
if (jwt == null || jwt.isBlank()) {
return false;
}
// 토큰이 유효한지 확인
try {
parser.parseSignedClaims(jwt);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
// 토큰 서명이 잘못되었거나, 잘못된 형식의 JWT가 전달된 경우
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
} catch (ExpiredJwtException e) {
// 토큰이 만료된 경우
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
} catch (UnsupportedJwtException e) {
// 지원되지 않는 JWT 형식이 전달된 경우
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
} catch (IllegalArgumentException e) {
// JWT 클레임이 비어 있거나 잘못된 형식일 경우
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
}
return false; // 예외가 발생한 경우 토큰이 유효하지 않음
}
}
JwtFilter 생성
@Slf4j(topic = "JwtFilter")
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUrl = request.getRequestURI();
// 로그인 API에 대해서는 로그인 인증 인가를 검증하지 않음
if (requestUrl.equals("/users/login")) {
filterChain.doFilter(request, response);
} else {
// JWT 검증
// 1단계 : header에 Authorization 값이 있는지 앖는지 확인
String authorizationHeader = request.getHeader("Authorization"); // HTTP header 중 Authorization 키 값에 넣어서 토큰을 보냄
// 2단계 : Authorization 안의 값이 Bearer 토큰인지 확인(다른 값을 넣어서 보낼 수도 있기 때문) -> 토큰을 보낼 때 'Bearer ...'로 prefix를 붙여서 보내주고 있음
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
// 토큰이 없기 때문에 예외 발생
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 필요 합니다.");
return;
}
// 3단계 : 유효한 토큰인지(내가 만든 토큰인지) 확인
String jwt = authorizationHeader.substring(7); // 'Bearer '를 제외하고 순수한 jwt 값만 가져오기
if (!jwtUtil.validateToken(jwt)) {
// 유효하지 않은 토큰인 경우, 예외 발생
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 필요 합니다.");
}
// 유효성 검사 모두 통과한 경우
// HttpServletRequest 안에 있는 attribute 저장 공간에 userId를 넣어서 컨트롤러에게 전달
Long userId = Long.valueOf(jwtUtil.getUserId(jwt));
request.setAttribute("userId", userId);
filterChain.doFilter(request, response);
}
}
}
로그인한 유저의 정보 받아오기
// Controller
public ResponseEntity<Void> getSchedule(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return ResponseEntity.ok().build();
}
<aside> 💡
@RequestAttribute를 통해 @SessionAttribute처럼 사용 가능!
// JwtFilter
// HttpServletRequest 안에 있는 attribute 저장 공간에 SessionUser 객체를 만들어서 넣어주기
request.setAttribute("loginUser", new SessionUser(userId, ":"));
filterChain.doFilter(request, response);
// Controller
public ResponseEntity<Void> getSchedule(@RequestAttribute(name = "loginUser") SessionUser sessionUser) {
sessionUser.getUserId();
sessionUser.getUserEmail();
return ResponseEntity.ok().build();
}
</aside>
JWT의 로그인 상태 확인 → 토큰이 있는지 없는지 / 있다면 내가 발급한 토큰이 맞는지 확인
⇒ 로그아웃 상태는 곧 토큰이 없는 상태!
토큰이 발급되면 프론트엔드에서 해당 토큰을 헤더에 넣어 이후의 내용들을 진행해줌
⇒ 로그아웃을 하면 프론트엔드에서 토큰을 헤더에서 지우면 됨!
즉, 백엔드에서는 로그아웃을 구현할 필요도 없다!