[CELEP]Spring Security + JWT Token - 실무편

HUNI·2024년 2월 28일

INTRO


Spring Security + JWT 토큰 - 개념편 을 보았는가?
이제 로그인 과정을 하나하나씩 코드 살펴보면서 분석해보자

BODY 1 : 발행한 토큰 저장하기


1. 클라이언트가 "/login" URL을 통해 서버에게 ID와 PW 정보를 전달한다.

//위치 : 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));
    }
  • @RequestBody란?
    서버와 클라이언트는 JSON 형식의 데이터를 주고 받는다. 이 때 스프링 MVC는 @RequestBody를 통해 HTTP요청 바디를 자바 객체로 변환한다.
  • @Valid 란?
    주로 RequestBody 로 받는 DTO를 검증하는 데 많이 사용된다. 해당 DTO 안에는 검증이 필요한 필드에 @Email, @NotBlank, @Valid ... 를 붙여주어 검증 작동하도록 한다.

2. 입력받은 로그인 정보를 통해 JWT 토큰 만든다

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를 사용함으로써 내가 원하는 필드에만 값을 넣어서 객체를 생성할 수 있다!

    출처 : https://squirmm.tistory.com/entry/SpringJava-Builder

3. 생성한 토큰을 저장하기 : Token 클래스를 분석해보자

// 위치 : 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;
    }
}
  • 왜 @Entity 부분을 주석처리를 했는가?
    Token 은 REDIS에 저장된다. REDIS는 Key-Value 형태의 NoSQL 데이터베이스 형태이고, JPA 는 관계형데이터베이스 형태이다. 따라서 Token은 @Entity를 통해 JPA의 관리를 받으면 안 된다.
  • @NoArgsConstructor 쓰는 이유는?
    @NoArgsConstructor 란 기본생성자를 만드는 LOMBOK 어노테이션이다. JPA는 Reflection을 이용해 엔티티 객체를 만드는데, 이 때 꼭 기본생성자가 필요하다.

  • @RedisHash 어떤 기능을 하지?
    RedisHash 를 통해 Redis에 넣을 Domain 객체를 Redish Hash 자료구조로 변환하는 방식이다. 이 때 timeToLive 옵션 기능으로 유효기간을 직접 정할 수 있다.

  • implements Serializable 왜 했을까?
    스프링 서버와 외부에서 실행 중인 레디스 서버는 서로 다른 어플리케이션이기 때문에 직렬화/역직렬화 과정이 필요하다.

BODY 2 : 토큰 검증하기


1. HTTP Request가 들어오면, Header에 담긴 JWT 토큰을 검증한다.

    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)) {
			
    }

2. 검증 후 AccessToken을 Authentication 객체로 만든다.

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());
    }
  • userDetails를 생성하여 UsernamePasswordAuthenticationToken을 생성한다.
  • Authentication 을 참조한다.
  • Authentiation 객체를 SecurityContextHolder에 담은 뒤 AuthenticationSuccessHandler를 실행한다.

** Filter 처리

Security Filter는 스프링 내의 DispatcherServelet에 속해있지 않기 때문에 일반적인 스프링 예외처리 방식을 따르면 안 된다. 검증하려는 필터 앞 단에 예외처리 필터를 두어야 함.

//위치 : _base > config > SecurityConfig
.addFilterBefore(new JwtTokenFilter(jwtTokenUtil),
                UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtTokenFilter.class)
  • 기본적으로 Spring 에서 UsernamePasswordAuthenticationFilter.class 를 제공한다.
profile
어려운 문제에 대해 고민하고 분석하는 과정을 좋아합니다.

0개의 댓글