지난 글에서 OAuth2를 통해 로그인을 구현했습니다.
이번 글에서는 로그인에 성공했을 때 사용자에게 Refresh 토큰을 발급해 주고,
Refresh 토큰으로 Access 토큰을 발급받아 사용하도록 구현해보겠습니다.
JWT 발급 전략
구현하기에 앞서 제가 선택한 JWT 발급 전략에 대해서 설명해보겠습니다.
OAuth2 로그인을 시도할 때 CORS 설정이 허용된 애플리케이션 서버에서 요청을 하기 위해서
사용자가 하이퍼링크로 서버의 url에 접근했었습니다.
이로 인해서 주체는 백엔드가 되었고, 클라이언트 페이지로 돌아오기 위해서 리디렉션을 해야 합니다.
따라서 요청과 응답 객체에 토큰을 포함할 경우 사용이 불가능하다는 문제가 생깁니다.
저는 이를 해결하기 위해서 Access 토큰 재발급을 위한 Refresh 토큰을 발급하고
보안을 위해 HTTP only로 설정된 쿠키에 담았습니다.
그 후 클라이언트의 Access 토큰 발급 요청을 하는 url로 리디렉션을 하여
클라이언트의 주체로 Access 토큰을 HTTP Header에 발급하고 메인 페이지로 이동했습니다.
이렇게 구현하면 메인페이지로 이동되었을 때 사용자의 로그인 정보를 사용할 수 있습니다.
또한, Access 토큰이 만료되면 Access 토큰 재발급을 애플리케이션 서버에 요청합니다.
이때 애플리케이션 서버에서는 Refresh 토큰과 Access 토큰 모두 재발급하여 응답해 주게 됩니다.
Refresh 토큰 또한 재발급하면서 로그인 유지시간을 늘리고, 만일의 보안적 문제가 발생하는 것을 방지합니다.
이제 구현해 봅시다!
Refresh 토큰 발급
Spring Security로 로그인에 성공 시 수행할 기능에 대해서
AuthenticationSuccessHandler 클래스를 상속받아
onAuthenticationSuccess 메서드를 재정의하여 수행할 수 있습니다.
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
private final JWTService jwtService;
// Refresh 토큰 타입을 정의해둔 enum
private static final String refresh = TokenType.REFRESH.value();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// OAuth2 리소스 서버에서 받아온 사용자의 정보
CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
String oauthData = customUserDetails.getOAuthData();
String name = customUserDetails.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// 사용자 정보를 통해 Refresh 토큰 생성 및 DB에 저장
String refreshToken = jwtUtil.createJwt(refresh, role, name, oauthData, JwtExpiredMs.REFRESH.value());
jwtService.saveRefreshToken(name, oauthData, refreshToken);
// 쿠키에 발급한 Refresh 토큰 추가
response.addCookie(HttpHeaderUtil.createCookie(refresh, refreshToken));
// 클라이언트의 Access 발급 url
response.sendRedirect("http://localhost:3000/issue/accessToken");
}
}
OAuth2 리소스 서버에서 받아온 사용자의 정보를 토대로 Refresh 토큰을 생성하고 DB에 저장합니다.
생성된 Refresh 토큰은 Http only 쿠키에 담아 클라이언트의 Access 토큰을 발급하는 url로 리디렉션 합니다.
Access 토큰 발급
쿠키에 담긴 Refresh 토큰을 통해 Access 토큰을 발급하기 위해서
클라이언트에서 토큰 발급을 요청하는 url로 HTTP 요청을 보내면 HTTP Header에 Access 토큰을 담아 응답하게 됩니다.
우선 WebSecurityConfig 클래스에서 필터를 거치지 않을 URL로 해당 url을 추가해 줍니다.
// 필터를 거치거나 거치지 않을 URL 정의
http
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/", "/reissue").permitAll()
.anyRequest().authenticated()
)
요청을 받을 컨트롤러를 만들어 Refresh 토큰을 통해 Refresh 토큰과 Access 토큰을 발급하고
헤더와 쿠키에 담아 응답합니다.
@RestController
@RequiredArgsConstructor
public class JwtController {
private final JWTService jwtService;
// 토큰 타입 enum
private static final String refresh = TokenType.REFRESH.value();
private static final String access = TokenType.ACCESS.value();
@PostMapping("/reissue")
public ResponseEntity<?> reissue(@CookieValue("refresh") Optional<String> refreshToken, HttpServletResponse response) {
try {
// 토큰이 없을 경우 에러 발생
// 토큰 생성 및 DB 저장
Map<String, String> tokens = jwtService.reissueTokens(refreshToken.orElseThrow(() ->
new IllegalArgumentException("Refresh token is missing")));
// 쿠키에 Refresh 토큰
response.addCookie(HttpHeaderUtil.createCookie(refresh, tokens.get(refresh)));
// 헤더에 Access 토큰
response.setHeader(access, tokens.get(access));
return ResponseEntity.status(HttpStatus.CREATED).build();
} catch (IllegalArgumentException | ExpiredJwtException e) {
// 만료된 토큰이거나 유효하지 않은 토큰일 경우
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal server error: " + e.getMessage());
}
}
}
Refresh 토큰과 Access 토큰을 발급하는 서비스의 비즈니스 로직은 다음과 같습니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JWTServiceImpl implements JWTService{
private final RefreshTokenRepository refreshTokenRepository;
private final JwtUtil jwtUtil;
private static final String access = TokenType.ACCESS.value();
private static final String refresh = TokenType.REFRESH.value();
private static final String bearer = TokenType.BEARER.value() + " ";
@Override
public Map<String, String> reissueTokens (String refreshToken) throws ExpiredJwtException, IllegalArgumentException {
//== Validation RefreshToken
// 만료 토큰인지 검증
jwtUtil.isExpired(refreshToken);
// refresh 토큰인지 검증
String category = jwtUtil.getCategory(refreshToken);
if (!category.equals(TokenType.REFRESH.value())) throw new IllegalArgumentException("invalid refresh token");
// DB에 존재하는지 검증
Boolean isExist = this.isExistRefreshToken(refreshToken);
if (!isExist) throw new IllegalArgumentException("invalid refresh token");
//== Make new JWT
String username = jwtUtil.getName(refreshToken);
String role = jwtUtil.getRole(refreshToken);
String oauthData = jwtUtil.getOauthData(refreshToken);
String newAccessToken = jwtUtil.createJwt(access, role, username, oauthData, JwtExpiredMs.ACCESS.value());
String newRefreshToken = jwtUtil.createJwt(refresh, role, username, oauthData, JwtExpiredMs.REFRESH.value());
// Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후
// 새로운 Refresh 토큰 저장
this.deleteRefreshToken(refresh);
this.saveRefreshToken(username, oauthData, newRefreshToken);
Map<String, String> tokens = new HashMap<>();
tokens.put(refresh, newRefreshToken);
tokens.put(access, bearer+newAccessToken);
return tokens;
}
}
마무리
이렇게 로그인이 성공했을 때 Refresh 토큰을 발급하고
클라이언트가 Access 토큰 발급을 요청하게 해서 Refresh 토큰과 Access 토큰을 성공적으로 발급했습니다.
JWT 유틸 클래스를 보여드리며 이번 글은 마무리하겠습니다.
다음 글에서 Spring Security 프레임워크로 JWT 필터를 추가하여
요청 url에 대한 적절한 토큰을 가지고 있는지 검증하도록 해보겠습니다.
@Component
public class JwtUtil {
private final SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}")String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public String getName(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("name", String.class);
}
public String getOauthData(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("oauth_data", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String category, String role, String name, String oauthData, Long expiredMs) {
return Jwts.builder()
.claim("category", category)
.claim("name", name)
.claim("oauth_data", oauthData)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
Reference
https://substantial-park-a17.notion.site/Docs-002024551c294889863d0c7923590568
'WEB > Spring' 카테고리의 다른 글
Service의 메서드들이 중복해서 사용하는 로직을 테스트하기 (0) | 2024.08.20 |
---|---|
RestControllerAdvice로 전역에서 발생된 예외 처리하기. (0) | 2024.05.01 |
Spring Security 프레임워크로 OAuth2 로그인 (0) | 2024.04.05 |
Spring Security가 뭐죠? (0) | 2024.03.24 |
[Spring JPA] IncorrectResultSizeDataAccessException (0) | 2023.12.05 |