Spring Security + JWT 토큰 - 개념편 을 보았는가?
이제 로그인 과정을 하나하나씩 코드 살펴보면서 분석해보자
//위치 : User > UserController.java
@Operation(summary = "로그인 토큰 생성", description = "Access, Refresh Token을 생성합니다.")
@PostMapping("/login")
public DataResponseDto<Object> login(@Valid @RequestBody LoginRequestDto loginRequestDto) {
return DataResponseDto.of(userService.login(loginRequestDto));
}
A. 로그인 코드 - 일부
// 위치 : user > UserService.java
public TokenDto login(LoginRequestDto loginRequestDto) {
//.. DTO를 통해 받은 ID 로 user 객체 만든다.
[1] Optional<User> user = userRepository.findByEmail(email);
if (user.isEmpty()) {
throw new GeneralException(Code.EMPTY_USER);
}
//클라이언트에게 전송할 때는 DTO 형식으로 전달해야 함. (보안을 위해)
TokenDto tokenDto = jwtTokenUtil.generateJwt(user.get());
Token token = Token.builder()
.userId(user.get().getId())
.refreshToken(tokenDto.getRefreshToken())
.expiredAt(tokenDto.getRefreshTokenExpiresIn())
.build();
}
B. JWT 토큰 생성 코드
//위치 : Security > jwt > jwtTokenUtil
public TokenDto generateJwt(User user) {
long now = (new Date()).getTime();
long refreshTokenExpireTimeMs = 1000 * 60 * 60 * 24 * 14L; // 14일
long accessTokenExpireTimeMs = 1000 * 60 * 30;
String accessToken = createAccessToken(user, accessTokenExpireTimeMs);
String refreshToken = createRefreshToken(user, refreshTokenExpireTimeMs);
[2] return TokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.refreshTokenExpiresIn(refreshTokenExpireTimeMs)
.build();
}
public String createAccessToken(User user, long expireTimeMs) {
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setSubject(user.getId().toString())
.claim("email", user.getEmail())
...
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
왜 Optional 자료형으로 받을까?
NPE 발생금지. 즉, DB에 "해당값 없음" 을 나타내기 위해 Optional 을 쓴다.
Optional은 메소드 반환값의 용도로만 쓰인다. 변수 선언할 때 자료형으로 절대 쓰이지 못함(직렬화 사용X)
Builder 사용하기
Builder를 사용함으로써 내가 원하는 필드에만 값을 넣어서 객체를 생성할 수 있다!
// 위치 : user > UserService.java
tokenRepository.save(token);
토큰 클래스 코드
[1] // @Entity
[2] @NoArgsConstructor
@Getter
[3] @RedisHash(value = "token", timeToLive = 60 * 60 * 24 * 14)// TTL 14일
// (TTL이란 Time To Live의 약자로, 캐시의 유효기간을 의미한다. 캐시의 유효기간이 지나면 캐시는 삭제된다. 단위는 초이다.)
public class Token implements Serializable {
// serialVersionId는 직렬화를 위한 고유 아이디이다.
private static final long serialVersionUID = 1L;
@Id
private String refreshToken;
// TODO: userId만을 이용해서 @Id를 설정하면, 한 유저가 여러 기기에서 로그인할 경우, 기존에 로그인한 기기의 토큰이 사라져 로그아웃되는 문제가 발생할 수 있다.
private Integer userId;
private Long expiredAt;
@Builder
public Token(Integer userId, String refreshToken, long expiredAt) {
this.userId = userId;
this.refreshToken = refreshToken;
this.expiredAt = expiredAt;
}
}
@NoArgsConstructor 쓰는 이유는?
@NoArgsConstructor 란 기본생성자를 만드는 LOMBOK 어노테이션이다. JPA는 Reflection을 이용해 엔티티 객체를 만드는데, 이 때 꼭 기본생성자가 필요하다.
@RedisHash 어떤 기능을 하지?
RedisHash 를 통해 Redis에 넣을 Domain 객체를 Redish Hash 자료구조로 변환하는 방식이다. 이 때 timeToLive 옵션 기능으로 유효기간을 직접 정할 수 있다.
implements Serializable 왜 했을까?
스프링 서버와 외부에서 실행 중인 레디스 서버는 서로 다른 어플리케이션이기 때문에 직렬화/역직렬화 과정이 필요하다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String accessToken = authorizationHeader.split(" ")[1];
if (jwtTokenUtil.validateToken(accessToken)) {
}
if (jwtTokenUtil.validateToken(accessToken)) {
Authentication authentication = jwtTokenUtil.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
public Authentication getAuthentication(String token) {
CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(
getUserId(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", Collections.emptyList());
}
Security Filter는 스프링 내의 DispatcherServelet에 속해있지 않기 때문에 일반적인 스프링 예외처리 방식을 따르면 안 된다. 검증하려는 필터 앞 단에 예외처리 필터를 두어야 함.
//위치 : _base > config > SecurityConfig
.addFilterBefore(new JwtTokenFilter(jwtTokenUtil),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtTokenFilter.class)