spring security + oauth2 + jwt
의 전체적인 흐름을 정리해보려고 한다.
인증을 위한 개방형 표준 프로토콜로, third-party 프로그램에게 리소스 소유자를 대신해서 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임하는 방식으로 작동된다.
쉽게 말해서 third-party 프로그램(구글, 카카오 등)에게 로그인 및 개인정보 관리에 대한 권한을 위임하여 third-party 프로그램이 가지고 있는 사용자에 대한 리소스를 조회할 수 있다.
1. 로그인 요청
Spring Security에서 기본적으로 제공하는 URL이 있다. -> http://{domain}/oauth2/authorization/{registrationId}
시큐리티에서 이미 구현을 해두었고, 따로 Controller를 만들지 않아도 된다.
4. 리다이렉트 URL
Spring Security에서 기본적으로 제공하는 URL이 있다. -> http://{domain}/login/oauth2/code/{registrationId}
시큐리티에서 이미 구현을 해두었고, 따로 Controller를 만들지 않아도 된다.
즉, 구현해야할 부분은 9. 최종 응답 전 후처리만 남게된다.
후처리는 로그인한 유저가 처음 가입하는 회원인지, 기존의 등록된 회원인지 검증 후 우리 애플리케이션에 접근할 수 있는 토큰을 발급해주는 것이다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT}
client-secret: ${GOOGLE_SECRET}
scope: # google API의 범위 값
- profile
- email
kakao:
client-id: ${KAKAO_CLIENT}
client-secret: ${KAKAO_SECRET}
redirect-uri: {baseUrl}/login/oauth2/code/kakao
client-authentication-method: client_secret_post # kakao는 인증 토큰 발급 요청 메서드가 post이다. (최근 버전에는 작성 방법이 이렇게 바뀌었다.)
authorization-grant-type: authorization_code
scope: # kakao 개인 정보 동의 항목 설정의 ID 값
- profile_nickname
- profile_image
- account_email
client-name: kakao
# kakao provider 설정
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id # 유저 정보 조회 시 반환되는 최상위 필드명으로 해야 한다.
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final TokenAuthenticationFilter tokenAuthenticationFilter;
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { // security를 적용하지 않을 리소스
return web -> web.ignoring()
// error endpoint를 열어줘야 함, favicon.ico 추가!
.requestMatchers("/error", "/favicon.ico");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// rest api 설정
.csrf(AbstractHttpConfigurer::disable) // csrf 비활성화 -> cookie를 사용하지 않으면 꺼도 된다. (cookie를 사용할 경우 httpOnly(XSS 방어), sameSite(CSRF 방어)로 방어해야 한다.)
.cors(AbstractHttpConfigurer::disable) // cors 비활성화 -> 프론트와 연결 시 따로 설정 필요
.httpBasic(AbstractHttpConfigurer::disable) // 기본 인증 로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable) // 기본 login form 비활성화
.logout(AbstractHttpConfigurer::disable) // 기본 logout 비활성화
.headers(c -> c.frameOptions(
FrameOptionsConfig::disable).disable()) // X-Frame-Options 비활성화
.sessionManagement(c ->
c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용하지 않음
// request 인증, 인가 설정
.authorizeHttpRequests(request ->
request.requestMatchers(
new AntPathRequestMatcher("/"),
new AntPathRequestMatcher("/auth/success"),
...
).permitAll()
.anyRequest().authenticated()
)
// oauth2 설정
.oauth2Login(oauth -> // OAuth2 로그인 기능에 대한 여러 설정의 진입점
// OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정을 담당
oauth.userInfoEndpoint(c -> c.userService(oAuth2UserService))
// 로그인 성공 시 핸들러
.successHandler(oAuth2SuccessHandler)
)
// jwt 관련 설정
.addFilterBefore(tokenAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new TokenExceptionFilter(), tokenAuthenticationFilter.getClass()) // 토큰 예외 핸들링
// 인증 예외 핸들링
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler()));
return http.build();
}
}
SecurityConfig
에서 로그인 성공 이후 사용자 정보를 가져올 클래스로
CustomOAuth2UserService
를 등록해준다.
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Transactional
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1. 유저 정보(attributes) 가져오기
Map<String, Object> oAuth2UserAttributes = super.loadUser(userRequest).getAttributes();
// 2. resistrationId 가져오기 (third-party id)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 3. userNameAttributeName 가져오기
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
// 4. 유저 정보 dto 생성
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(registrationId, oAuth2UserAttributes);
// 5. 회원가입 및 로그인
Member member = getOrSave(oAuth2UserInfo);
// 6. OAuth2User로 반환
return new PrincipalDetails(member, oAuth2UserAttributes, userNameAttributeName);
}
private Member getOrSave(OAuth2UserInfo oAuth2UserInfo) {
Member member = memberRepository.findByEmail(oAuth2UserInfo.email())
.orElseGet(oAuth2UserInfo::toEntity);
return memberRepository.save(member);
}
}
DefaultOAuth2UserService
는 리소스 서버에서 사용자 정보를 받아오는 클래스인데, 이를 상속 받아 사용자 정보(DefaultOAuth2User
의 attributes)를 가져온다.
구글 기준 attributes
{
"sub": "1234567890",
"name": "user-name",
"email": "user-email",
...
}
registrationId
가져오기
registrationId
는 oauth 관련 yml에서 설정한 client.registration의 값을 말한다. (google, kakao)
userNameAttributeName
가져오기
oauth 관련 yml에서 설정한 provider의 user-name-attribute 값을 말한다. (구글은 "sub"이다. CommonOAuth2Provider에서 확인 가능하다.) 이는 유저 attributes에서 식별자에 접근할 때 사용된다. -> attributes.get("sub") (DefaultOAuth2User에서 확인 가능하다.)
유저 정보 dto 생성
어떤 소셜 로그인인지 구별하여 유저 정보 dto(OAuth2UserInfo
)를 생성한다.
@Builder
public record OAuth2UserInfo(
String name,
String email,
String profile
) {
public static OAuth2UserInfo of(String registrationId, Map<String, Object> attributes) {
return switch (registrationId) { // registration id별로 userInfo 생성
case "google" -> ofGoogle(attributes);
case "kakao" -> ofKakao(attributes);
default -> throw new AuthException(ILLEGAL_REGISTRATION_ID);
};
}
private static OAuth2UserInfo ofGoogle(Map<String, Object> attributes) {
return OAuth2UserInfo.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.profile((String) attributes.get("picture"))
.build();
}
private static OAuth2UserInfo ofKakao(Map<String, Object> attributes) {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return OAuth2UserInfo.builder()
.name((String) profile.get("nickname"))
.email((String) account.get("email"))
.profile((String) profile.get("profile_image_url"))
.build();
}
public Member toEntity() {
return Member.builder()
.name(name)
.email(email)
.profile(profile)
.memberKey(KeyGenerator.generateKey())
.role(Role.USER)
.build();
}
}
registrationId
별로 유저 정보를 생성한다.
attributes
의 키 값은 각 소셜의 응답 값(소셜 사이트 확인)을 보면 알 수 있다.
회원가입 및 로그인
생성한 유저 정보를 가지고 이전에 가입한 회원인지 확인 후 새로운 회원이면 저장한다.
OAuth2User
로 반환
new DefaultOAuth2User()로 반환하지 않고, 인증 객체 생성 시 member에 대한 값을 추가하기 위해 Principal 객체를 작성해주었다.
public record PrincipalDetails(
Member member,
Map<String, Object> attributes,
String attributeKey) implements OAuth2User, UserDetails {
@Override
public String getName() {
return attributes.get(attributeKey).toString();
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority(member.getRole().getKey()));
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return member.getMemberKey();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails
도 같이 구현하여 토큰 생성 시 authentication
객체에서 getName()
호출 시 getUsername()
값이 리턴되도록 했다.
위와 같이 작동하는 이유는 내부 코드를 통해 확인할 수 있다. (TokenProvider
에서 구현된다.)
authentication.getName() -> Principal 객체의 getName()을 호출한다.
Principal 객체에 담기는 것은 UserDetails를 구현하여 직접 생성한 PrincipalDetails 객체이다.
AbstractAuthenticationToken에서 getName() 호출 시 principal이 UserDetails이면 userDetails.getUsername()을 리턴하도록 되어있기 때문이다.
여기까지 구현했다면 OAuth2 로그인은 끝났다. (시큐리티 덕분에 구현의 양이 확 줄었기 때문이다.)
이제 OAuth2 로그인 성공 시 인증 토큰을 발급해주는 부분만 남았다.
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private static final String URI = "/auth/success";
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// accessToken, refreshToken 발급
String accessToken = tokenProvider.generateAccessToken(authentication);
tokenProvider.generateRefreshToken(authentication, accessToken);
// 토큰 전달을 위한 redirect
String redirectUrl = UriComponentsBuilder.fromUriString(URI)
.queryParam("accessToken", accessToken)
.build().toUriString();
response.sendRedirect(redirectUrl);
}
}
인증 토큰 발급은 로그인에 성공했다는 조건이 있기 때문에 로그인이 성공적으로 끝나면 호출되는 successHandler
에서 발급해준다.
발급된 토큰을 프론트에게 응답으로 내려주기 위해 내부적으로 리다이렉트를 해주었다.
@RequiredArgsConstructor
@Component
public class TokenProvider {
@Value("${jwt.key}")
private String key;
private SecretKey secretKey;
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60L * 24 * 7;
private static final String KEY_ROLE = "role";
private final TokenService tokenService;
@PostConstruct
private void setSecretKey() {
secretKey = Keys.hmacShaKeyFor(key.getBytes());
}
public String generateAccessToken(Authentication authentication) {
return generateToken(authentication, ACCESS_TOKEN_EXPIRE_TIME);
}
// 1. refresh token 발급
public void generateRefreshToken(Authentication authentication, String accessToken) {
String refreshToken = generateToken(authentication, REFRESH_TOKEN_EXPIRE_TIME);
tokenService.saveOrUpdate(authentication.getName(), refreshToken, accessToken); // redis에 저장
}
private String generateToken(Authentication authentication, long expireTime) {
Date now = new Date();
Date expiredDate = new Date(now.getTime() + expireTime);
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining());
return Jwts.builder()
.subject(authentication.getName())
.claim(KEY_ROLE, authorities)
.issuedAt(now)
.expiration(expiredDate)
.signWith(secretKey, Jwts.SIG.HS512)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
List<SimpleGrantedAuthority> authorities = getAuthorities(claims);
// 2. security의 User 객체 생성
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
private List<SimpleGrantedAuthority> getAuthorities(Claims claims) {
return Collections.singletonList(new SimpleGrantedAuthority(
claims.get(KEY_ROLE).toString()));
}
// 3. accessToken 재발급
public String reissueAccessToken(String accessToken) {
if (StringUtils.hasText(accessToken)) {
Token token = tokenService.findByAccessTokenOrThrow(accessToken);
String refreshToken = token.getRefreshToken();
if (validateToken(refreshToken)) {
String reissueAccessToken = generateAccessToken(getAuthentication(refreshToken));
tokenService.updateToken(reissueAccessToken, token);
return reissueAccessToken;
}
}
return null;
}
public boolean validateToken(String token) {
if (!StringUtils.hasText(token)) {
return false;
}
Claims claims = parseClaims(token);
return claims.getExpiration().after(new Date());
}
private Claims parseClaims(String token) {
try {
return Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
return e.getClaims();
} catch (MalformedJwtException e) {
throw new TokenException(INVALID_TOKEN);
} catch (SecurityException e) {
throw new TokenException(INVALID_JWT_SIGNATURE);
}
}
}
refreshToken
발급
refreshToken
은 발급 시 accessToken
을 key로 redis에 저장한다. 유효기간은 refreshToken
의 만료일과 동일하게 잡았다. 또한 refreshToken
은 프론트에게 전달하지 않고 백엔드에서만 가지고 있을 계획이라서 accessToken
과 refreshToken
생성 부분은 공통으로 사용하였다.
security의 User
객체 생성
토큰을 파싱하여 Authentication
객체를 리턴하는 메서드에서 데이터베이스에서 접근하고 싶지 않았기 때문에 UserDetails
를 구현한 User
객체를 생성하였다. 이는 추후 Controller에서 UserDetails
를 받기 위한 이유도 된다.
accessToken
재발급
accessToken
은 refreshToken
으로 재발급되는데, 과정은 이러하다.
먼저 filter에서 accessToken validation을 거친다. 만료가 되었다면 재발급을 시도한다.
redis에서 accessToken으로 refreshToken을 찾고, validation을 거친다. 만료되지 않았다면 accessToken을 재발급하고 redis에 업데이트 한다.
위 코드와 같이 tokenProvider
에서 발생하는 예외가 좀 있다.
parseClaim() 메서드에서 토큰을 파싱하는 부분, refreshToken을 조회하는 부분인데 예외가 발생하는 것을 그냥 둔다면 말 그대로 exception을 던지고 만다. 필터 단에서 던져지는 예외는 @ControllerAdvice가 처리할 수 없기 때문이다. (@ControllerAdvice는 Servlet에서 발생하는 예외만 핸들링할 수 있다.)
그래서 토큰 관련 예외를 처리하기 위해 예외를 핸들링하는 필터를 만들어 주었다.
public class TokenExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (TokenException e) {
response.sendError(e.getErrorCode().getHttpStatus().value(), e.getMessage());
}
}
}
여기서 바로 ErrorResponse
를 구성하여 response.getWriter().write()
로 필터에서 바로 응답을 써주도록 해도 되지만, 간단하게 처리하기 위해 sendError()로 servlet으로 예외를 전달했다. (사실 예외를 전달하는 것보다 바로 응답을 내려주는게 속도 측면에서는 더 빠를 것이다. 예외를 전달하는데 걸리는 과정이 꽤 길었다. 예외 발생 지점부터 servlet까지 계속 전달하고, 다시 컨트롤러로 내려가야하기 때문이다.)
참고) sendError()가 호출되면 모든 에러는 "/error"로 간다. 이 엔드포인트는 스프링 부트가 만들어둔 BasicErrorController에 매핑되어 있어서 이 컨트롤러에서 응답을 내려준다.
토큰 예외는 간단하게 처리하였고, 이 필터를 사용하도록 등록해줘야 한다.
위치는 TokenAuthenticationFilter 전으로 등록하여 주면 된다. 코드는 위에 작성된 SecurityConfig를 참고하자.
이제 마지막으로 토큰에 대한 인증 처리를 하는 TokenAuthenticationFilter를 보자.
@RequiredArgsConstructor
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveToken(request);
// accessToken 검증
if (tokenProvider.validateToken(accessToken)) {
setAuthentication(accessToken);
} else {
// 만료되었을 경우 accessToken 재발급
String reissueAccessToken = tokenProvider.reissueAccessToken(accessToken);
if (StringUtils.hasText(reissueAccessToken)) {
setAuthentication(reissueAccessToken);
// 재발급된 accessToken 다시 전달
response.setHeader(AUTHORIZATION, TokenKey.TOKEN_PREFIX + reissueAccessToken);
}
}
filterChain.doFilter(request, response);
}
private void setAuthentication(String accessToken) {
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader(AUTHORIZATION);
if (ObjectUtils.isEmpty(token) || !token.startsWith(TokenKey.TOKEN_PREFIX)) {
return null;
}
return token.substring(TokenKey.TOKEN_PREFIX.length());
}
}
accessToken
을 가져온 뒤 유효한지 검증한다.accessToken
을 재발급하고, 재발급된 accessToken
을 헤더에 실어 다음 필터로 보낸다.