문제&해결

OAuth 소셜 로그인 안전하게 구현하기 (redirect_uri, redis, token)

mint* 2024. 9. 21. 21:32
728x90

서론

OAuth의 기본 개념 소개

  • OAuth의 핵심은 인증과 인가를 구분하는 것
    • 인증 : 사용자가 자신의 신원을 증명하는 것
      • ex) id, pw로 로그인
    • 인가: 인증된 사용자에게 특정 리소스에 대한 접근 권한을 부여하는 과정
      • ex) access token
    • 인증과 인가를 구분함으로써 인증과 인가 로직을 따로 설정할 수 있습니다.
      • 인증은 2단계 인증으로, 인가는 세밀한 권한 제어 설정이 가능합니다.
  • OAuth가 탄생한 이유는 보안 수준이 검증되지 않은 여러 플랫폼에 동일한 로그인 정보를 사용하는 위험을 줄이기 위해서입니다.
    • 신뢰할 수 있는 플랫폼이 인증과 권한 부여를 담당함으로써, 사용자는 더 안전하게 여러 서비스를 이용할 수 있게 됩니다.

 

로그인 방식

1. 프론트엔드에서 직접 소셜 로그인 요청을 보내는 방식

  • 구현이 간단하고 빠릅니다.
  • 서버 부하가 감소합니다.
  • 클라이언트 시크릿을 프론트엔드에 노출시킬 위험이 있습니다.
    • OAuth 2.0 명세에 따르면 권장되지 않습니다.
  • 토큰 관리시 보안상 좋지 않습니다.

 

2. 백엔드를 통해 소셜 로그인을 처리하는 방식 (추천)

  • 클라이언트 시크릿을 안전하게 보관할 수 있습니다.
  • 토큰 관리를 서버에서 하므로(관리 중앙화) 보안이 강화됩니다.
    • 유저의 시크릿이 유출되지 않습니다.
    • 권한 서버(ex) 카카오)의 토큰을 서버에 저장하고, 액세스 토큰과 리프레시 토큰은 서버(우리 서비스)가 발급해서 줍니다.
  • 사용자 정보를 서버 측에서 검증할 수 있습니다.
  • 구현이 조금 더 복잡하고, 서버에 추가적으로 부하가 발생할 수 있습니다.

블로그 글은 백엔드를 통해 소셜 로그인을 구현하였습니다.

 

2. OAuth 라이브러리의 편리함

라이브러리가 제공하는 기능들

  1. 자동 URL 생성: /oauth2/authorization/{provider-id}
  2. 인증 요청 리다이렉트: OAuth2AuthorizationRequestRedirectFilter가 자동으로 OAuth 제공자의 로그인 페이지로 리다이렉트합니다.
  3. 콜백 처리: 인증 코드를 받아 처리하는 과정을 OAuth2LoginAuthenticationFilter가 자동으로 수행합니다.
  4. 토큰 요청 및 갱신: 액세스 토큰 요청, 갱신 등의 과정을 자동으로 처리합니다.
  5. 사용자 정보 로드: OAuth 제공자로부터 사용자 정보를 자동으로 가져옵니다.

 

개발자가 작성할 부분

  • 사용자 데이터 DB 저장
  • JWT 토큰 생성 및 관리
  • 커스텀 인증 로직 구현

OAuth의 기술 컨셉만 제대로 이해했다면 구현 과정이 훨씬 빠르고 쉬웠을 것 같다.

프로바이더에 독립적인 액세스 토큰 생성하기

프로바이더 토큰을 바로 사용하면 안된다

  • 외부 의존성 증가: 프로바이더의 토큰 형식이나 정책이 변경되면 서비스에 영향을 미친다.
  • 보안 문제: 프로바이더 토큰 노출시 프로바이더가 제공하는 다른 서비스에 악용될 수 있다.
  • 설정 제한 : 토큰에 담길 정보를 결정할 수 없다.

 

자체적으로 액세스 토큰을 생성하자

  • 프로바이더 토큰은 그대로 두고, 자체 액세스 토큰을 생성하면 위의 단점들을 극복할 수 있다.
  • 독립성: OAuth2 프로바이더에 종속되지 않고 여러 프로바이더를 지원할 수 있다.
  • 보안 : 토큰 노출시 리스크가 줄어든다.
  • 토큰 관리가 쉽다.

 

4. 안전한 토큰 전달 방식

리다이렉트 URI에 쿼리로 토큰을 전달하는 것은 위험하다.

  • 많은 프로젝트에서 인증 후 액세스 토큰을 리다이렉트 URI의 쿼리 파라미터로 전달하였다.
  • 하지만 액세스 토큰이 URL에 노출되는 문제가 있고, 브라우저 히스토리에 저장되어 후에 접근이 가능하다.
  • 보안상 위험이 있으므로 다른 방식을 생각해볼 필요가 있다.
  • 리다이렉트(3xx) 시에는 응답 body로 데이터를 직접 보낼 수 없는 HTTP 프로토콜의 한계도 고려해야한다.

 

임시 토큰(temp token) 발급 방식

  • 서버에서 임시 토큰을 생성하여 access token 대신 전달한다.
  • 클라이언트는 임시 토큰을 통해 access token을 요청할 수 있다.

 

임시 토큰을 이용한 액세스 토큰 발급 과정

  1. 임시 토큰 생성: 인증 성공 후, 서버는 UUID를 사용하여 임시 토큰을 생성한다.
  2. Redis에 저장: 생성된 임시 토큰과 실제 액세스 토큰을 키-값 쌍으로 Redis에 저장한다.
  3. 클라이언트에게 임시 토큰 전달
  4. 액세스 토큰 요청 - 응답
  5. 임시 토큰 삭제 (일회용)

 

임시 토큰 저장 방식

세션 저장 vs Redis 저장

  • 세션 저장
    • 처음에 구현의 간단함을 이유로 세션에 저장하였다.
    • 하지만 서버 재시작시 데이터가 손실되고, 다중 서버에서 동기화 문제가 발생 가능하였다.
  • Redis 사용
    • Redis를 사용하면 외부 서버이므로 서버 재시작과 관계없이 데이터가 유지된다.
    • 분산 환경에서도 데이터를 일관적으로 사용이 가능하다.
    • 만료 시간 설정을 쉽게 할 수 있다.

 

OAuth 소셜 로그인 구현하기

  • java 17
  • Spring Boot 3.2.5
  • Spring Security 6.2.4

 

1. OAuth2 클라이언트 설정

application.yml

  # OAuth  
  spring.security:  
    oauth2.client:  
      registration:  
        google:  
          clientId: ${GOOGLE_CLIENT_ID}  
          clientSecret: ${GOOGLE_CLIENT_SECRET}  
          redirectUri: http://localhost:8080/login/oauth2/code/google  
          scope:  
        kakao:  
          clientId: ${KAKAO_CLIENT_ID}  
          clientSecret: ${KAKAO_CLIENT_SECRET}  
          clientAuthenticationMethod: client_secret_post  
          authorizationGrantType: authorization_code  
          redirectUri: http://localhost:8080/login/oauth2/code/kakao  
          scope:  
          clientName: Kakao  
      # Provider 설정  
      provider:  
        kakao:  
          authorizationUri: https://kauth.kakao.com/oauth/authorize  
          tokenUri: https://kauth.kakao.com/oauth/token  
          userInfoUri: https://kapi.kakao.com/v2/user/me  
          userNameAttribute: id  

app:  
  auth:  
    jwt:  
      secret-key: ${JWT_SECRET_KEY}  
      access-expired: ${ACCESS_EXPIRED}  
      refresh-expired: ${REFRESH_EXPIRED}  
    temp-token:  
      expiration-minutes: 5  
  oauth2:  
    redirect-uris:  
      - http://localhost:3000/login/oauth2/redirect  
      - https://{배포주소}/login/oauth2/redirect  
    default-redirect-uri: https://{배포주소}/login/oauth2/redirect  

2. 사용자 정보 처리 서비스 구현

CustomOAuth2UserService 클래스를 만들어 OAuth2 인증 후 사용자 정보를 처리한다.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User user = super.loadUser(userRequest);
        try {
            return this.process(userRequest, user);
        } catch (Exception ex) {
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
        }
    }

    private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
        AuthProvider authProvider = AuthProvider.valueOf(
                userRequest.getClientRegistration().getRegistrationId().toUpperCase());
        OAuth2MemberInfo memberInfo = OAuth2MemberInfoFactory.getOAuth2MemberInfo(authProvider, user.getAttributes());

        Optional<Member> memberOptional = memberRepository.findByOauthId(memberInfo.getId());
        Member member;
        if (memberOptional.isPresent()) {
            member = memberOptional.get();
            if (authProvider != member.getAuthProvider()) {
                throw new OAuthProviderMissMatchException();
            }
        } else {
            member = createMember(memberInfo, authProvider);
        }
        return CustomUserDetails.create(member, user.getAttributes());
    }

    private Member createMember(OAuth2MemberInfo memberInfo, AuthProvider authProvider) {
        Member member = Member.createMemberWithOAuthInfo(memberInfo, authProvider);
        return memberRepository.save(member);
    }
}

3. 인증 성공 핸들러 구현

OAuth2AuthenticationSuccessHandler 클래스를 만들어 인증 성공 시 처리를 구현한다.

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final AuthService authService;
    private final AppProperties appProperties;
    private final RedisTemplate<String, String> redisTemplate;
    private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            logger.debug("응답이 이미 커밋되었습니다. " + targetUrl + "로 리다이렉트 할 수 없습니다");
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new BadRequestException("승인되지 않은 리다이렉션 URI입니다");
        }

        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        String token = tokenProvider.createToken(authentication);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("token", token)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);

        return appProperties.getOauth2().getAuthorizedRedirectUris()
                .stream()
                .anyMatch(authorizedRedirectUri -> {
                    URI authorizedURI = URI.create(authorizedRedirectUri);
                    return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                            && authorizedURI.getPort() == clientRedirectUri.getPort();
                });
    }
}

4. 보안 설정

SecurityConfig 클래스에서 OAuth2 로그인 설정을 추가한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .cors()
                .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            .csrf()
                .disable()
            ..
            // OAuth2 로그인  
            .oauth2Login(oauth2 -> oauth2  
                    .authorizationEndpoint(authorization -> authorization  
                            .baseUri("/oauth2/authorization")  
                            .authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository))  
                    .userInfoEndpoint(userInfo -> userInfo  
                            .userService(oAuth2UserService))  
                    .successHandler(successHandler)  
            ).oauth2Client(Customizer.withDefaults());

        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

5. 회원 정보 클래스

인증을 통해 얻은 회원 정보에 대응되는 클래스이다.

public abstract class OAuth2MemberInfo {  
  
    protected Map<String, Object> attributes;  
  
    public OAuth2MemberInfo(Map<String, Object> attributes) {  
        this.attributes = attributes;  
    }  
  
    public Map<String, Object> getAttributes() {  
        return attributes;  
    }  
  
    public abstract String getId();  
}

 

kakao

public class KakaoOAuth2MemberInfo extends OAuth2MemberInfo {  
    public KakaoOAuth2MemberInfo(Map<String, Object> attributes) {  
        super(attributes);  
    }  
  
    @Override  
    public String getId() {  
        return attributes.get("id").toString();  
    }  
}

 

Provider에 따라 회원 정보 생성하는 클래스

public class OAuth2MemberInfoFactory {  
    public static OAuth2MemberInfo getOAuth2MemberInfo(AuthProvider authProvider, Map<String, Object> attributes) {  
        switch (authProvider) {  
            case KAKAO -> {  
                return new KakaoOAuth2MemberInfo(attributes);  
            }  
            case GOOGLE -> {  
                return new GoogleOAuth2MemberInfo(attributes);  
            }  
            default -> throw new UnsupportedProviderException();  
        }  
    }  
}

 

6. OAuth2 인증 저장소

  • OAuth2 인증 요청을 HTTP 쿠키에 저장하고 관리하는 Repository 클래스이다.
  • OAuth2 인증 프로세스에서 Spring Security OAuth2 클라이언트에 의해 호출되며, 인증 과정에서 상태를 유지하는 데 사용된다.
```java
@Component  
public class HttpCookieOAuth2AuthorizationRequestRepository implements  
        AuthorizationRequestRepository<OAuth2AuthorizationRequest> {  
  
    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";  
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";  
    private static final int cookieExpireSeconds = 180;  
  
    /**  
     * 로그인 수행시, OAuth2 인증 요청을 HTTP 쿠키에 저장한다. 리다이렉트 URI도 함께 저장한다.  
     *     * @param authorizationRequest 저장할 OAuth2 인증 요청  
     * @param request              HTTP 요청  
     * @param response             HTTP 응답  
     */  
    @Override  
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,  
                                         HttpServletResponse response) {  
        if (authorizationRequest == null) {  
            removeAuthorizationRequestCookies(request, response);  
            return;  
        }  
  
        addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,  
                serialize(authorizationRequest), cookieExpireSeconds);  
  
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);  
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {  
            addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);  
        }  
    }  
  
    /**  
     * 로그인 완료 후 우리 서비스로 복귀할 때 사용한다. 쿠키로부터 HTTP 요청에서 저장했던 OAuth2 인증 요청을 로드한다.  
     *     * @param request HTTP 요청  
     * @return 저장된 OAuth2 인증 요청, 없으면 null  
     */    @Override  
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {  
        return getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)  
                .map(cookie -> deserialize(cookie.getValue(), OAuth2AuthorizationRequest.class))  
                .orElse(null);  
    }  
  
    /**  
     * 인증이 끝났으니 사용했던 쿠키들을 제거한다.  
     *     * @param request  HTTP 요청  
     * @param response HTTP 응답  
     * @return 저장된 OAuth2 인증 요청, 없으면 null  
     */    @Override  
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,  
                                                                 HttpServletResponse response) {  
        return this.loadAuthorizationRequest(request);  
    }  
  
    /**  
     * OAuth2 인증 요청과 관련된 쿠키들을 제거한다.  
     *     * @param request  HTTP 요청  
     * @param response HTTP 응답  
     */  
    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {  
        deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);  
        deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);  
    }  
  
    /**  
     * 저장된 리다이렉트 URI를 가져온다.  
     *     * @param request HTTP 요청  
     * @return 저장된 리다이렉트 URI, 없으면 null  
     */    public String getRedirectUriAfterLogin(HttpServletRequest request) {  
        return getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)  
                .map(cookie -> cookie.getValue())  
                .orElse(null);  
    }  
}

 

7. 임시 토큰 발급 및 관리

인증 성공 후 임시 토큰을 발급하고 Redis에 저장한다.

String tempToken = UUID.randomUUID().toString();
redisTemplate.opsForValue()
        .set(tempToken, accessToken, appProperties.getAuth().getTempToken().getExpirationMinutes(),
                TimeUnit.MINUTES);

클라이언트는 이 임시 토큰을 사용하여 실제 액세스 토큰을 요청한다.

@PostMapping("/token")
public ResponseEntity<Map<String, String>> getAccessToken(@RequestParam("temp-token") String tempToken) {
    String accessToken = redisTemplate.opsForValue().get(tempToken);
    if (accessToken != null) {
        redisTemplate.delete(tempToken);
        return ResponseEntity.ok(Map.of("access_token", accessToken));
    }
    return ResponseEntity.badRequest().build();
}

 

처음 사용한 기술도 있었지만 생각보다 구현이 잘 되어서 즐거웠다.

728x90