이전 포스트까지는 OAuth2 Client를 활용하여 별도의 컨트롤러 구현없이 소셜로그인 기능을 구현하였습니다. 하지만 단지 로그인 기능만 구현한다면 사용자는 번거롭게 매번 로그인 해주어야 하는 불편함이 존재합니다. 지속적인 로그인 유지를 위해 사용되는 방식으로는 크게 세션방식과 JWT를 활용하는 방식 2가지가 있는데 저는 JWT를 활용하였습니다. 이번 포스트에서는 JWT 엑세스/리프레쉬 토큰 구현 및 어떻게 기능들이 동작하는지 알아보겠습니다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
private String accessToken;
private String refreshToken;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "refresh_token")
@Entity
public class RefreshToken {
@Id
private String memberId;
private String value;
public void updateValue(String token) {
this.value = token;
}
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Optional<RefreshToken> findByMemberId(String memberId);
}
@Slf4j
@Component
public class TokenProvider {
private final MemberRepository memberRepository;
private static final String AUTHORITIES_KEY = "auth";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey,
MemberRepository memberRepository) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.memberRepository = memberRepository;
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 1516239022 (예시)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
Member member = memberRepository.findById(Long.parseLong(claims.getSubject())).orElse(null);
if(member == null)
throw new CustomRuntimeException(MEMBER_NOT_FOUND_ERREOR);
UserDetails principal = new PrincipalDetails(member);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
jwt:
secret: {secret_key}
dependencies{
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
여기까지 우선 토큰을 발행하는 과정을 알아보았습니다. 이제 JWT필터를 만들고 Security필터에 우리가 만든 필터를 등록해야합니다. 지금부터는 필터를 구현하는 코드와 필터를 통과했을 경우와 그렇지 않을 경우의 핸들러를 구현해보겠습니다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 403 FORBIDDEN 응답 생성
ApiResponse<Object> apiResponse = ApiResponse.onFailure(FORBIDDEN_ERROR);
// JSON 직렬화
String jsonResponse = objectMapper.writeValueAsString(apiResponse);
// 응답 설정
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 응답 전송
PrintWriter out = response.getWriter();
out.println(jsonResponse);
out.flush();
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 응답 생성
ApiResponse<Object> apiResponse = ApiResponse.onFailure(UNAUTHORIZED_ERROR);
// JSON 직렬화
String jsonResponse = objectMapper.writeValueAsString(apiResponse);
// 응답 설정
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 응답 전송
PrintWriter out = response.getWriter();
out.println(jsonResponse);
out.flush();
}
}
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
// TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@JsonPropertyOrder({"isGuest", "accessToken", "refreshToken"})
@Builder
public class OAuth2LoginResDto {
private String accessToken;
private String refreshToken;
private boolean isGuest;
@JsonProperty("isGuest")
public boolean isGuest(){
return isGuest;
}
@JsonProperty("accessToken")
public String getAccessToken(){
return accessToken;
}
@JsonProperty("refreshToken")
public String getRefreshToken(){
return refreshToken;
}
}
@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final PrincipalOauth2UserService principalOauth2UserService;
private final RefreshTokenRepository refreshTokenRepository;
private final AppleProperties appleProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient(CustomRequestEntityConverter customRequestEntityConverter) {
DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
accessTokenResponseClient.setRequestEntityConverter(customRequestEntityConverter);
return accessTokenResponseClient;
}
@Bean
public CustomRequestEntityConverter customRequestEntityConverter() {
return new CustomRequestEntityConverter(appleProperties);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((auth) -> auth.disable())
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.cors((co)->co.configurationSource(configurationSource()))
.formLogin((auth) -> auth.disable())
.httpBasic((auth)->auth.disable())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2Login -> oauth2Login
.tokenEndpoint(tokenEndpointConfig -> tokenEndpointConfig.accessTokenResponseClient(accessTokenResponseClient(customRequestEntityConverter())))
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(principalOauth2UserService))
.successHandler(successHandler()))
.exceptionHandling((auth)->
auth.authenticationEntryPoint(jwtAuthenticationEntryPoint).accessDeniedHandler(jwtAccessDeniedHandler))
.with(new JwtSecurityConfig(tokenProvider), c-> c.getClass())
.sessionManagement(sm->sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public AuthenticationSuccessHandler successHandler() {
return (request, response, authentication) -> {
// PrincipalDetails로 캐스팅하여 인증된 사용자 정보를 가져온다.
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
boolean isGuest = false;
if(principal.getMember().getAuthority().equals(Authority.ROLE_GUEST))
isGuest = true;
// jwt token 발행을 시작한다.
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
RefreshToken refreshToken = RefreshToken.builder()
.memberId(principal.getUsername())
.value(tokenDto.getRefreshToken())
.build();
RefreshToken existRefreshToken = refreshTokenRepository.findByMemberId(principal.getUsername()).orElse(null);
if(existRefreshToken == null)
refreshTokenRepository.save(refreshToken);
else {
existRefreshToken.updateValue(tokenDto.getRefreshToken());
refreshTokenRepository.save(existRefreshToken);
}
OAuth2LoginResDto oAuth2LoginResDto = OAuth2LoginResDto.builder()
.accessToken(tokenDto.getAccessToken())
.refreshToken(tokenDto.getRefreshToken())
.isGuest(isGuest)
.build();
ObjectMapper objectMapper = new ObjectMapper();
ApiResponse<OAuth2LoginResDto> apiResponse = ApiResponse.onSuccess(SuccessStatus._OK, oAuth2LoginResDto);
// JSON 직렬화
String jsonResponse = objectMapper.writeValueAsString(apiResponse);
// 응답 설정
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 응답 전송
PrintWriter out = response.getWriter();
out.println(jsonResponse);
out.flush();
};
}
@Bean
public CorsConfigurationSource configurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
configuration.addExposedHeader("Authorization");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@Tag(name = "Jwt 토큰 재발급 API")
@RestController
@RequestMapping("api/auth")
@RequiredArgsConstructor
public class JwtController {
private final JwtService jwtService;
@Operation(summary = "토큰 재발급 API", description = "리프레쉬 토큰을 검증한 후 액세스 토큰을 재발급합니다.")
@PostMapping("/reissue")
public ResponseEntity<ApiResponse<?>> reissue(@RequestHeader(value = "Authorization") String accessToken, @RequestHeader(value = "refreshToken") String refreshToken){
//Bearer 접두사 삭제
accessToken = accessToken.substring(7);
return jwtService.reissue(accessToken,refreshToken);
}
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JwtService {
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public ResponseEntity<ApiResponse<?>> reissue(String accessToken, String refreshToken){
// 1. Refresh Token 검증
if (!tokenProvider.validateToken(refreshToken))
throw new CustomRuntimeException(BADREQUEST_ERROR);
// 2. Access Token 에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(accessToken);
PrincipalDetails principalDetails = (PrincipalDetails)authentication.getPrincipal();
String memberId = principalDetails.getUsername();
RefreshToken existRefreshToken = refreshTokenRepository.findByMemberId(memberId).orElse(null);
if(existRefreshToken == null)
throw new CustomRuntimeException(BADREQUEST_ERROR);
// 3. DB에 매핑 되어있는 Member ID(key)와 Vaule값이 같지않으면 에러 리턴
if(!refreshToken.equals(existRefreshToken.getValue()))
throw new CustomRuntimeException(BADREQUEST_ERROR);
// 4. Vaule값이 같다면 토큰 재발급 진행
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
OAuth2LoginResDto oAuth2LoginResDto = OAuth2LoginResDto.builder()
.accessToken(tokenDto.getAccessToken())
.refreshToken(refreshToken)
.isGuest(principalDetails.getMember().getAuthority().equals(Authority.ROLE_GUEST)?true:false)
.build();
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, oAuth2LoginResDto));
}
}
이로써 길고 긴 Spring Oauth2 Client + JWT를 이용한 소셜로그인 구현이 끝이 났습니다. 처음에는 정리하기 막막하고 귀찮은 과정이었지만 막상 끝내고 나니 두고두고 다시 보면서 정리할 수 있을것 같아 뿌듯합니다.... 읽어주셔서 감사합니다.