[스프링] spring security + oauth2 + jwt

동동주·2024년 5월 10일
0

spring security + oauth2 + jwt 의 전체적인 흐름을 정리해보려고 한다.

OAuth 2.0 (Open Authoriztion 2.0)

인증을 위한 개방형 표준 프로토콜로, 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. 최종 응답 전 후처리만 남게된다.

후처리는 로그인한 유저가 처음 가입하는 회원인지, 기존의 등록된 회원인지 검증 후 우리 애플리케이션에 접근할 수 있는 토큰을 발급해주는 것이다.


1. ✴️9번에 해당하는 구현 과정

  • 카카오 소셜 로그인 기반이다.

1-1. application-oauth.yml

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 # 유저 정보 조회 시 반환되는 최상위 필드명으로 해야 한다.

1-2. SecurityConfig

@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();
    }
}

1-3. CustomOAuth2UserService

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);
    }
}
  • 주석 설명
  1. 유저 attributes 가져오기

DefaultOAuth2UserService는 리소스 서버에서 사용자 정보를 받아오는 클래스인데, 이를 상속 받아 사용자 정보(DefaultOAuth2User의 attributes)를 가져온다.

구글 기준 attributes
{
"sub": "1234567890",
"name": "user-name",
"email": "user-email",
...
}

  1. registrationId 가져오기
    registrationId는 oauth 관련 yml에서 설정한 client.registration의 값을 말한다. (google, kakao)

  2. userNameAttributeName 가져오기
    oauth 관련 yml에서 설정한 provider의 user-name-attribute 값을 말한다. (구글은 "sub"이다. CommonOAuth2Provider에서 확인 가능하다.) 이는 유저 attributes에서 식별자에 접근할 때 사용된다. -> attributes.get("sub") (DefaultOAuth2User에서 확인 가능하다.)

  3. 유저 정보 dto 생성
    어떤 소셜 로그인인지 구별하여 유저 정보 dto(OAuth2UserInfo)를 생성한다.

1-4. 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의 키 값은 각 소셜의 응답 값(소셜 사이트 확인)을 보면 알 수 있다.

  • 주석 설명
  1. 회원가입 및 로그인
    생성한 유저 정보를 가지고 이전에 가입한 회원인지 확인 후 새로운 회원이면 저장한다.

  2. OAuth2User로 반환
    new DefaultOAuth2User()로 반환하지 않고, 인증 객체 생성 시 member에 대한 값을 추가하기 위해 Principal 객체를 작성해주었다.

1-5. PrincipalDetails

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 로그인 성공 시 인증 토큰을 발급해주는 부분만 남았다.

2. OAuth2 로그인 성공 시 인증 토큰을 발급

2-1. OAuth2SucessHandler

@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에서 발급해준다.
발급된 토큰을 프론트에게 응답으로 내려주기 위해 내부적으로 리다이렉트를 해주었다.

2-2. TokenProvider

@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);
        }
    }
}
  • 주석 설명
  1. refreshToken 발급
    refreshToken은 발급 시 accessToken을 key로 redis에 저장한다. 유효기간은 refreshToken의 만료일과 동일하게 잡았다. 또한 refreshToken은 프론트에게 전달하지 않고 백엔드에서만 가지고 있을 계획이라서 accessTokenrefreshToken 생성 부분은 공통으로 사용하였다.

  2. security의 User 객체 생성
    토큰을 파싱하여 Authentication 객체를 리턴하는 메서드에서 데이터베이스에서 접근하고 싶지 않았기 때문에 UserDetails를 구현한 User 객체를 생성하였다. 이는 추후 Controller에서 UserDetails를 받기 위한 이유도 된다.

  3. accessToken 재발급
    accessTokenrefreshToken으로 재발급되는데, 과정은 이러하다.

먼저 filter에서 accessToken validation을 거친다. 만료가 되었다면 재발급을 시도한다.
redis에서 accessToken으로 refreshToken을 찾고, validation을 거친다. 만료되지 않았다면 accessToken을 재발급하고 redis에 업데이트 한다.

위 코드와 같이 tokenProvider에서 발생하는 예외가 좀 있다.

parseClaim() 메서드에서 토큰을 파싱하는 부분, refreshToken을 조회하는 부분인데 예외가 발생하는 것을 그냥 둔다면 말 그대로 exception을 던지고 만다. 필터 단에서 던져지는 예외는 @ControllerAdvice가 처리할 수 없기 때문이다. (@ControllerAdvice는 Servlet에서 발생하는 예외만 핸들링할 수 있다.)

그래서 토큰 관련 예외를 처리하기 위해 예외를 핸들링하는 필터를 만들어 주었다.


3. 토큰 관련 예외를 처리

3-1. TokenExceptionFilter

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를 보자.

3-2. 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());
    }
}
  • 코드 flow
    request 헤더에서 accessToken을 가져온 뒤 유효한지 검증한다.
    유효하다면 인증 객체를 생성하고 요청을 다음 필터로 보낸다.
    유효하지 않다면 accessToken을 재발급하고, 재발급된 accessToken을 헤더에 실어 다음 필터로 보낸다.
    📕출처: https://do5do.tistory.com/20

0개의 댓글