구현 내용 :
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
'Spring' 카테고리의 다른 글
외부 메서드, 내부 메서드에 대한 @Transactional 트랜잭션 적용 결과 테스트 (0) | 2023.06.22 |
---|---|
Jsoup 라이브러리를 통한 정적 페이지 크롤링 (0) | 2023.03.18 |
Proxy로 생성되는 Service와 의존관계를 갖는 @Repository, JpaRepository는 Proxy일까? (2) | 2022.09.09 |
username만 매칭되면 user 세션 생성 되는 문제 해결 - AuthenticationProvider를 통한 password 기반 인증 (0) | 2022.05.30 |
로그인 성공 시 이전 페이지로 이동 - Referer 헤더와 AuthenticationSuccessHandler extends (0) | 2022.05.30 |