JWT를 사용한 로그인 및 Refresh Token을 활용한 로그인 상태 유지
Spring

JWT를 사용한 로그인 및 Refresh Token을 활용한 로그인 상태 유지

구현 내용 : 

1. Spring Security + JWT를 사용해 username, password를 통해 로그인 완료 시 Access Token 및 Refresh Token 발급

2. Access Token 만료 시 Refresh Token을 통한 Access Token 재발급 (로그인 상태 유지)

    2-1. Refresh Token의 갱신 주기 도달 시 Refresh Token도 함께 재발급 (로그인 유지 기간 연장)

3. Access Token + Refresh Token 만료 시 Error Response를 통한 인증 만료 처리

 

 

설계 및 Response : 

- AT : Access Token / 만료기간 : 12시간

- RT : Refresh Token / 만료기간 : 3개월 / refresh 요청할 때 만료기간 1개월 미만 시 Refresh Token도 함께 재발급

 

- “/login” : ID / PW로 로그인

  • 최초 로그인 시 Response Body에 AT1, RT1 반환
  • DB에도 User Entity의 RefreshToken Column에 RT1 저장

 

- “/request” : with AT1 (AT1 만료 안된 경우)

  • 헤더에 Authorization: Bearer [AT1] 를 담아 요청

  • AT1 만료 안된 경우 검증 후 자원 정상적으로 반환

 

- “/request” : with AT1 (AT1 만료 된 경우)

  • AT1이 만료되어 401 에러 반환

 

- “/refresh” : with RT1 (RT1 만료 안된 경우 + 갱신 할 필요 없는 경우)

  • 헤더에 Authorization: Bearer [RT1] 를 담아 요청
  • RT1이 갱신 주기가 아니므로 새롭게 발급된 AT2만 반환

  • 이후 위 AT2로 Request

 

- “/request” : with AT2 (AT2 만료 된 경우)

  • 헤더에 Authorization: Bearer [AT2] 를 담아 요청
  • AT2이 만료되어 401 에러 반환

 

 

- “/refresh” : with RT1 (RT1 만료 안된 경우 + 갱신 주기에 도달한 경우)

  • 헤더에 Authorization: Bearer [RT1] 를 담아 요청
  • RT1이 갱신 주기이므로 새롭게 발급된 AT3, RT2 모두 반환

  • DB에도 User Entity의 RefreshToken Column에 RT2 저장
  • 이후 위 AT3로 Request

 

 

- “/request” : with AT3 (AT3 만료 된 경우)

  • 헤더에 Authorization: Bearer [AT3] 를 담아 요청
  • AT3이 만료되어 401 에러 반환

 

 

- “/refresh” : with RT2 (RT2 만료된 경우)

  • 헤더에 Authorization: Bearer [RT2] 를 담아 요청
  • RT2이 만료되어 401 에러 반환

  • 클라이언트 측에서는 재로그인 요청(”/login”)을 통해 다시 AT, RT를 발급받아야 함.

 

 

- “/login” : ID / PW로 로그인

  • 다시 로그인 시 Response Body에 새로 발급된 AT4, RT3 반환

  • DB에도 User Entity의 RefreshToken Column에 RT3 저장
  • 이후 위 AT4로 Request

 

 

핵심 구현 내용 : 

 

- "/login" 요청 시 AT, RT 발급 로직

 

1. 클라이언트 측에서 Request Parameter로 username/password를 포함해 로그인 요청시

구현한 UsernamePasswordAuthenticationFilter가 동작하여 AuthenticationToken을 생성한 후

AuthenticationManager에게 인증 요청

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
        return authenticationManager.authenticate(token);
    }
}

 

2. 구현한 AuthenticationProvider가 AuthenticationToken을 통해 DB 내 username/password 비교를 통해 인증.

성공 시 Authentication 객체 반환

public class CustomAuthProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        // PW 검사
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("Provider - authenticate() : 비밀번호가 일치하지 않습니다.");
        }

        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

 

3. 2번에서 로그인 성공 시 구현한 AuthenticationSuccessHandler가 동작. 

AT 및 RT 생성 후 response에 반환

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    User user = (User) authentication.getPrincipal();

    String accessToken = JWT.create()
            .withSubject(user.getUsername())
            .withExpiresAt(new Date(System.currentTimeMillis() + AT_EXP_TIME))
            .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
            .withIssuedAt(new Date(System.currentTimeMillis()))
            .sign(Algorithm.HMAC256(JWT_SECRET));
    String refreshToken = JWT.create()
            .withSubject(user.getUsername())
            .withExpiresAt(new Date(System.currentTimeMillis() + RT_EXP_TIME))
            .withIssuedAt(new Date(System.currentTimeMillis()))
            .sign(Algorithm.HMAC256(JWT_SECRET));

    // Refresh Token DB에 저장
    accountService.updateRefreshToken(user.getUsername(), refreshToken);

    // Access Token , Refresh Token 프론트 단에 Response Header로 전달
    response.setContentType(APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("utf-8");
    response.setHeader(AT_HEADER, accessToken);
    response.setHeader(RT_HEADER, refreshToken);

    Map<String, String> responseMap = new HashMap<>();
    responseMap.put(AT_HEADER, accessToken);
    responseMap.put(RT_HEADER, refreshToken);
    new ObjectMapper().writeValue(response.getWriter(), responseMap);
}

 

 

- 인증된 사용자만 접근할 수 있는 엔드포인트 "/request" 요청 시 ~ 자원 반환 로직

 

OncePerRequestFilter를 상속하여 구현한 CustomAuthorizationFilter가 인증을 필요로 하는 매 요청마다 동작하여 Request Header에 넘어온 AT에 대한 유효성 검사.

1. 유효하지 않은 AT 또는 만료된 AT일 경우 Error Response 처리

2. 유효한 AT일 경우 SecurityContext에 인증된 Authentication 저장 후 정상적으로 다음 Filter 수행하여 자원 반환

 

public class CustomAuthorizationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String servletPath = request.getServletPath();
        String authrizationHeader = request.getHeader(AUTHORIZATION);

        // 로그인, 리프레시 요청이라면 토큰 검사하지 않음
        if (servletPath.equals("/api/login") || servletPath.equals("/api/refresh")) {
            filterChain.doFilter(request, response);
        } else if (authrizationHeader == null || !authrizationHeader.startsWith(TOKEN_HEADER_PREFIX)) {
            // 토큰값이 없거나 정상적이지 않다면 400 오류
            log.info("CustomAuthorizationFilter : JWT Token이 존재하지 않습니다.");
            response.setStatus(SC_BAD_REQUEST);
            response.setContentType(APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("utf-8");
            ErrorResponse errorResponse = new ErrorResponse(400, "JWT Token이 존재하지 않습니다.");
            new ObjectMapper().writeValue(response.getWriter(), errorResponse);
        } else {
            try {
                 // Access Token만 꺼내옴
                String accessToken = authrizationHeader.substring(TOKEN_HEADER_PREFIX.length());

                // === Access Token 검증 === //
                JWTVerifier verifier = JWT.require(Algorithm.HMAC256(JWT_SECRET)).build();
                DecodedJWT decodedJWT = verifier.verify(accessToken);
                
                // === Access Token 내 Claim에서 Authorities 꺼내 Authentication 객체 생성 & SecurityContext에 저장 === //
                List<String> strAuthorities = decodedJWT.getClaim("roles").asList(String.class);
                List<SimpleGrantedAuthority> authorities = strAuthorities.stream().map(SimpleGrantedAuthority::new).toList();
                String username = decodedJWT.getSubject();
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                
                // 인증 처리 후 정상적으로 다음 Filter 수행
                filterChain.doFilter(request, response);
            } catch (TokenExpiredException e) {
                log.info("CustomAuthorizationFilter : Access Token이 만료되었습니다.");
                response.setStatus(SC_UNAUTHORIZED);
                response.setContentType(APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("utf-8");
                ErrorResponse errorResponse = new ErrorResponse(401, "Access Token이 만료되었습니다.");
                new ObjectMapper().writeValue(response.getWriter(), errorResponse);
            } catch (Exception e) {
                log.info("CustomAuthorizationFilter : JWT 토큰이 잘못되었습니다. message : {}", e.getMessage());
                response.setStatus(SC_BAD_REQUEST);
                response.setContentType(APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("utf-8");
                ErrorResponse errorResponse = new ErrorResponse(400, "잘못된 JWT Token 입니다.");
                new ObjectMapper().writeValue(response.getWriter(), errorResponse);
            }
        }
    }
}

 

 

- "/refresh” 로직

Service 단의 refresh 메서드에서 다음 과정을 통해 refresh 동작 수행

1. Refresh Token 유효성 검사

 1-1. 유효하지 않거나 만료된 Refresh Token 일 시 Error Response

2. Access Token 재발급

3. 현재시간과 Refresh Token의 만료일을 통해 남은 만료기간 계산

4. Refresh Token의 남은 만료기간이 1개월 미만일 시 Refresh Token도 재발급

@Override
public Map<String, String> refresh(String refreshToken) {

    // === Refresh Token 유효성 검사 === //
    JWTVerifier verifier = JWT.require(Algorithm.HMAC256(JWT_SECRET)).build();
    DecodedJWT decodedJWT = verifier.verify(refreshToken);

    // === Access Token 재발급 === //
    long now = System.currentTimeMillis();
    String username = decodedJWT.getSubject();
    Account account = accountRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    if (!account.getRefreshToken().equals(refreshToken)) {
        throw new JWTVerificationException("유효하지 않은 Refresh Token 입니다.");
    }
    String accessToken = JWT.create()
            .withSubject(account.getUsername())
            .withExpiresAt(new Date(now + AT_EXP_TIME))
            .withClaim("roles", account.getRoles().stream().map(Role::getName)
                    .collect(Collectors.toList()))
            .sign(Algorithm.HMAC256(JWT_SECRET));
    Map<String, String> accessTokenResponseMap = new HashMap<>();

    // === 현재시간과 Refresh Token 만료날짜를 통해 남은 만료기간 계산 === //
    // === Refresh Token 만료시간 계산해 1개월 미만일 시 refresh token도 발급 === //
    long refreshExpireTime = decodedJWT.getClaim("exp").asLong() * 1000;
    long diffDays = (refreshExpireTime - now) / 1000 / (24 * 3600);
    long diffMin = (refreshExpireTime - now) / 1000 / 60;
    if (diffMin < 5) {
        String newRefreshToken = JWT.create()
                .withSubject(account.getUsername())
                .withExpiresAt(new Date(now + RT_EXP_TIME))
                .sign(Algorithm.HMAC256(JWT_SECRET));
        accessTokenResponseMap.put(RT_HEADER, newRefreshToken);
        account.updateRefreshToken(newRefreshToken);
    }

    accessTokenResponseMap.put(AT_HEADER, accessToken);
    return accessTokenResponseMap;
}

 

 


Github 소스코드 : https://github.com/HunSeongPark/jwt-normal-login

 

GitHub - HunSeongPark/jwt-normal-login: username / password를 통한 JWT 로그인 구현

username / password를 통한 JWT 로그인 구현. Contribute to HunSeongPark/jwt-normal-login development by creating an account on GitHub.

github.com