[패스트캠퍼스X야놀자 : 미니 프로젝트] 로그인 후 JWT Access Token 및 Refresh Token 발급 구현하기

꼬마요리사레미·2023년 12월 7일

본 포스팅은 사용자 로그인 시도 시 Spring Security Filter Chain 을 거쳐 사용자 인증이 정상적으로 완료된 상태에서 JWT 가 발급되는 과정을 설명한다.

Access Token 은 Response Body 에, Refresh Token 은 Cookie 에 설정한다.

💡 JwtTokenProvider

  • JwtTokenProvider 클래스는 JWT (JSON Web Token)를 생성하고 검증하는 등 JWT 관련된 모든 기능을 전담하여 수행하는 클래스이다.
  • 로그인 시도 시 사용자의 인증 정보를 기반으로하여 액세스 토큰과 리프레시 토큰을 발급하며, 이를 포함한 토큰 정보를 TokenInfo 객체로 반환한다.

💡 Access Token & Refresh Token

  • 액세스 토큰 (Access Token)
    사용자의 신원을 확인하고 권한을 부여하는 데 사용된다. 토큰은 일정 기간 동안 유효하며, 이 기간이 만료되면 재인증이 필요하다.
  • 리프레시 토큰 (Refresh Token)
    액세스 토큰이 만료되었을 때, 리프레시 토큰을 사용하여 새로운 액세스 토큰을 갱신한다.
  • 토큰 만료 및 갱신
    주로 보안적인 이유로 액세스 토큰이 짧게 유지되고, 리프레시 토큰이 보다 오래 유지된다.
public TokenInfo generateTokenInfo(Authentication authentication, HttpServletResponse response) {
        
        // 현재 시간 및 사용자 권한 가져오기
        long currentTimeMillis = System.currentTimeMillis();
        String authorities = getAuthorities(authentication);

        // 액세스 토큰 및 리프레시 토큰 생성
        String accessToken = generateToken(authentication.getName(), authorities, currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME);
        String refreshToken = generateToken(authentication.getName(), authorities, currentTimeMillis + REFRESH_TOKEN_EXPIRE_TIME);

        // 엑세스 토큰을 쿠키에 저장
        storeRefreshTokenInCookie(response, refreshToken);

        // 토큰 정보를 담은 객체 생성
        TokenInfo tokenInfo = TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME)
                .refreshToken(refreshToken)
                .build();

        return tokenInfo;
}

✅ Access Token 및 Refresh Token 생성

private String generateToken(String subject, String authorities, long expiration) {
    Date expirationDate = new Date(expiration);

    JwtBuilder jwtBuilder = Jwts.builder()
            .setSubject(subject) 
            .claim(AUTHORITIES_KEY, authorities) 
            .setExpiration(expirationDate) 
            .signWith(key, SignatureAlgorithm.HS512); 
            
    return jwtBuilder.compact();
}

💡 JWT (JSON Web Token)

  1. JWT 페이로드에 포함되는 클레임 집합의 구성요소로는 다음과 같다.
sub (Subject) : 토큰의 주체를 나타낸다. 여기서는 사용자 아이디를 사용하였다.
auth (Authorities) : 사용자의 권한을 나타낸다. 여기서는 역할 정보를 사용하였다.
exp (Expiration Time) : 토큰의 만료 시간을 나타낸다.
  1. JWT 헤더 및 서명에 포함되는 구성요소로는 다음과 같다.
alg (Algorithm): 암호화 알고리즘을 나타낸다. 여기서는 HMAC-SHA512 알고리즘을 사용하여 서명하였다.
typ (Type) : 토큰 타입을 나타낸다. 여기서는 JWT 를 사용하였다.
sign (Signature): 앞의 헤더와 페이로드를 비밀 키로 서명한 결과를 나타낸다.

헤더와 페이로드는 Base64 인코딩된 문자열로 구성되며, 서명은 HMAC-SHA512 알고리즘을 사용하여 헤더와 페이로드를 비밀키로 서명한다. 이들은 점(.) 으로 구분되어 JWT가 완성된다.

Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload) + "." + Signature
  1. compact() 메서드는 빌더 객체를 사용하여 JWT 문자열로 변환한다.
private void storeRefreshTokenInCookie(HttpServletResponse response, String refreshToken) {
    
    ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, refreshToken)
            .domain(".tr1ll1on.site")
            .httpOnly(true)
            .secure(true)
            .path("/")
            .sameSite("None")
            .build();

    response.addHeader("Set-Cookie", cookie.toString());
}

아래와 같은 옵션들이 적용된 ResponseCookie 객체를 생성하고, Set-Cookie 를 통해 브라우저에 쿠키를 저장한다.

🍪 SameSite : 쿠키가 어떤 상황에서 전송되는지 제어하는 역할

  • None : 쿠키가 모든 상황에서 전송되도록 허용한다. Cross-Origin 인 경우에도 쿠키 전송을 허용한다. 이를 사용하려면 Secure 속성도 함께 설정되어야 한다.
  • Lax : Cross-Origin 인 경우 GET 요청의 메서드에서만 쿠키를 전송할 수 있다.
  • Strict : 모든 상황에서 쿠키가 전송되려면 해당 요청이 완전히 같은 사이트 내부에서 이루어져야 한다. 즉 Same-Origin 인 경우에만 가능하다.

✋ 여기서 잠깐 : Same-Origin 및 Same-Site 이해

  • Same-Origin 및 Cross-Origin
    동일한 스키마, 호스트 이름, 포트가 조합된 웹사이트는 Same-Origin (동일 출처) 로 간주된다. 그 외 모든 것은 Cross-Origin (교차 출처) 로 간주된다.
  • Same-Site 및 Cross-Site
    스키마와 eTLD+1이 동일한 웹사이트는 Same-Site (동일 사이트) 로 간주된다. 스키마가 다르거나 eTLD+1이 다른 웹사이트는 Cross-Site (교차 사이트) 로 간주된다.

🍪 Secure : 프로토콜에 따른 쿠키 전송 여부를 결정

  • true : HTTPS 를 통해 통신하는 경우에만 쿠키를 전송한다.

🍪 HttpOnly : 자바 스크립트에서 쿠키 접근 여부를 결정

  • true : JavaScript 로 쿠키에 접근이 불가능하다.

🍪 Path : 쿠키가 전송될 수 있는 경로 설정

  • / : 모든 경로의 요청에서 쿠키 전송이 가능하다.
  • /auth : /auth 를 포함한 세부 경로의 요청에서만 쿠키 전송이 가능하다.

브라우저 쿠키에 리프레시 토큰이 잘 담겨진 모습을 볼 수 있다.

✅ TokenInfo 구성

TokenInfo tokenInfo = TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME)
                .refreshToken(refreshToken)
                .build();

해당 토큰의 그랜트 타입을 Bearer Token 로 설정한다. Bearer Token 그랜트 타입은 액세스 토큰 자체가 인증 수단으로 사용되며, 요청의 헤더에 토큰을 담아서 보낸다.

✅ Response Body 에 TokenInfo 담기

@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
        @RequestBody LoginRequest loginRequest, HttpServletResponse response
) {
    return ResponseEntity.ok(authService.login(loginRequest, response));
}


@Transactional
public LoginResponse login(LoginRequest loginRequest, HttpServletResponse response) {
    Authentication authentication = authenticateUser(loginRequest);
    TokenInfo tokenInfo = jwtTokenProvider.generateTokenInfo(authentication, response);

    Long userId = Long.valueOf(authentication.getName());
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new InValidUserException(InValidUserExceptionCode.USER_NOT_FOUND));

    return LoginResponse.builder()
            .userDetails(
                    LoginResponse.UserDetailsResponse.builder()
                            .userId(userId)
                            .userEmail(user.getEmail())
                            .userName(user.getName())
                            .build()
            )
            .tokenInfo(tokenInfo)
            .build();
}

응답 바디에 토큰과 관련된 정보가 잘 담긴 것을 볼 수 있다.

✅ Response Header 에 Access Token 담기

개발 초기에는 해당 Access Token 은 Bearer Token 타입으로, HTTP Response Header 의 Authorization 에 추가하여 전송하였다.

다만 프론트 엔드 쪽에서 Header 에서 Access Token 을 추출하는 과정이 번거롭다 하여, Body 에 담아서 보내는 방식으로 변경하였다.

해당 포스팅에서 Header 에 담아 보내는 과정도 기록하려고 한다.

@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
    return authService.login(loginRequest, response);
}
@Transactional(readOnly = true)
public ResponseEntity<LoginResponse> login(LoginRequest loginRequest, HttpServletResponse response) {
    Authentication authentication = authenticate(loginRequest);
    TokenDto tokenDTO = jwtTokenProvider.generateTokenDto(authentication, response);

    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + tokenDTO.getAccessToken());

    User user = userRepository.findByEmail(loginRequest.getEmail())
            .orElseThrow(() -> new UserNotFoundException(TrillionExceptionCode.USER_NOT_FOUND));

    LoginResponse loginResponse = LoginResponse.builder()
            .email(user.getEmail())
            .id(user.getId())
            .name(user.getName())
            .build();

    return ResponseEntity.ok().headers(headers).body(loginResponse);
}

✅ jwt.io 디버거를 사용하여 JWT를 디코딩 하기

이렇게 생성된 JWT 토큰을 JWT Debugger 에서 디코딩하고 검증할 수 있다.

0개의 댓글