ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • spring security oauth2 (vscode,web) 로그인 환경 구축
    카테고리 없음 2025. 10. 28. 11:04
    728x90
    반응형

    1.용어 정리(중요)

    • Authorization Server: 구글
    • Client(또는 RP): 스프링 기반 웹 앱 (로그인 시작/콜백 처리 주체)
    • Resource Server: 여러분 API 서버(액세스 토큰으로 보호 자원 제공). 실서비스에선 Client와 Resource Server가 같은 앱일 수도 있지만 역할은 구분해서 생각하세요.
    • OIDC(OpenID Connect): “로그인(신원)”을 다루는 확장 규격. 구글 로그인은 사실상 OIDC 사용이 표준입니다.

     

    2. oauth2 로그인 전체 플로우 

    [브라우저] 
       │ 1. GET /oauth2/authorization/{registrationId}   ← 로그인 버튼/링크
       ▼
    [Spring Security Filter Chain]
       │
       │ (A) OAuth2AuthorizationRequestRedirectFilter
       │     - 여기서 ▶ OAuth2AuthorizationRequestResolver.resolve(req)
       │     - scope, state, nonce(oidc), 추가 파라미터 세팅
       │     - AuthorizationRequestRepository.save(...)
       │     - 302 Redirect → Authorization Server (AS) /authorize
       ▼
    [Authorization Server (예: Google)]
       │ 2. 사용자 인증/동의
       │ 3. redirect_uri 로 code(+state) 반환
       ▼
    [브라우저] 
       │ 4. GET /login/oauth2/code/{registrationId}?code=...&state=...
       ▼
    [Spring Security Filter Chain]
       │
       │ (B) OAuth2LoginAuthenticationFilter
       │     - AuthorizationRequestRepository.loadAndRemove(...)
       │     - (백엔드) code → token 교환: OAuth2AccessTokenResponseClient
       │     - (OIDC면) id_token 검증
       │     - 사용자 조회: OAuth2UserService / OidcUserService
       │     - Authentication 생성 및 SecurityContext 저장
       │     - 성공 핸들러로 리다이렉트(기본 홈페이지 등)
       ▼
    [애플리케이션]
       │ 5. 인증된 세션/보안 컨텍스트로 앱 접근

     

     

    3. vscode,web 로그인 분기처리

    현재 vscode는 oauth2로그인후 클라이언트 서버에서 state 정보로 검증을 진행한뒤 otc 코드값을 발행후 vscode에서 해당 otc code로 jwt를 교환하는 로그인 방식이다. 

    하지만 web 방식은 위와같은 방식으로 진행할필요없고. 기본적인 oauth2로그인 방식으로 진행해야한다.

    그럴려면 일단 두방식에 대한 분기 처리할곳이 필요하고 해당 부분이 어디인지 알아보자

     

     

     

    1) OAuth 2.0 로그인 시작 단계와 Resolver의 자리

    브라우저가 /oauth2/authorization/{registrationId} (예: /oauth2/authorization/google)로 서버에 요청하면, Spring Security의OAuth2AuthorizationRequestRedirectFilter 가 가로채서 인가 요청 URL을 만들고 인증 서버(구글/애플 등)로 리다이렉트(302)합니다.

    이때 “인가 요청을 어떻게 만들지”를 결정하는 컴포넌트가 OAuth2AuthorizationRequestResolver 입니다.
    기본 구현체는 DefaultOAuth2AuthorizationRequestResolver 인데, 프로젝트 요구(플랫폼 구분, 파라미터 검증, 공급자별 옵션)에 맞게 커스터마이즈 하려고 감싸는(Decorator) 방식으로 현재 클래스를 만든 거예요.

    한 줄 요약: 로그인 “시작” 지점에 끼어들어 → 요청에서 기기/플랫폼 정보를 수집 → 인가 요청의 state/추가 파라미터를 꾸며서 권한 서버로 리다이렉트 하게 만듭니다.

     

    2) 왜 state에 정보를 담나? (왕복 가능한 컨텍스트)

    OAuth2의 state는 원래 클라이언트 상태를 왕복시키라고 있는 표준 필드입니다.
    로그인 시작 시 서버가 state에 “컨텍스트”를 넣어 두면, 콜백 시( (위 전체 플로우에 4.) /login/oauth2/code/... ) 그대로 돌아오므로 서버가 시작 시점의 정보를 세션 없이도 복원할 수 있습니다.

    OAuth2AuthorizationRequestResolver  에서 ClientRequest DTO 로 만들고 → JSON → Base64URL 로 인코딩하여 state에 담습니다.
    이렇게 하면 SuccessHandler에서 state만 디코드해 플랫폼 분기(웹=기존 로그인, VS Code=OTC 교환, 모바일=앱 딥링크 등)를 정확히 수행할 수 있죠.

     

     

    @Component
    public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
    
        private final JwtProperties jwtProperties;
        private final DefaultOAuth2AuthorizationRequestResolver defaultResolver;
    
        public CustomOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository,
                                                        JwtProperties jwtProperties) {
            this.jwtProperties = jwtProperties;
            this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(
                    clientRegistrationRepository,
                    OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI
            );
        }
    
        @Override
        public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
            defaultResolver.setAuthorizationRequestCustomizer(authorizationRequestCustomizer(request));
            return defaultResolver.resolve(request);
        }
    
        @Override
        public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
            defaultResolver.setAuthorizationRequestCustomizer(authorizationRequestCustomizer(request));
            return defaultResolver.resolve(request, clientRegistrationId);
        }
    
        private Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer(HttpServletRequest request) {
            return customizer -> {
                ClientRequest clientRequest = ClientRequest.builder()
                        .platform(getDevicePlatformParameter(request))
                        .did(getDeviceIdParameter(request))
                        .push(getDevicePushParameter(request))
                        .agent(getDeviceAgentHeader(request))
                        .remoteIp(getDeviceRemoteIp(request))
                        .redirectUri(getDeviceRedirectUriParameter(request))
                        .timestamp(Timestamp.valueOf(LocalDateTime.now()).getTime()).build();
    
                try {
                    customizer.state(Base64.getUrlEncoder().encodeToString(JsonUtil.toJSONBytes(clientRequest)));
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
    
                customizer.attributes(attributes -> {
                    String registrationId = (String) attributes.get("registration_id");
                    switch (registrationId) {
                        case "google" -> customizer.additionalParameters(params -> params.put("prompt", "consent"));
                        case "apple" -> customizer.additionalParameters(params -> params.put("response_mode", "form_post"));
                        default -> {
                        }
                    }
                });
            };
        }
    
        public CodeEnum.Platform getDevicePlatformParameter(HttpServletRequest request) {
            String platform = request.getParameter(OAuth2.PLATFORM_PARAM_NAME);
            return platform != null ? CodeEnum.Platform.of(platform) : CodeEnum.Platform.UNKNOWN;
        }
    
        public String getDeviceIdParameter(HttpServletRequest request) {
            CodeEnum.Platform platform = getDevicePlatformParameter(request);
            switch (platform) {
                case ANDROID, IOS -> {
                    String did = request.getParameter(OAuth2.DEVICE_ID_PARAM_NAME);
                    if (did == null) {
                        throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_PARAMETER_DID);
                    }
                    return did;
                }
                case WEB -> {
                    return null;
                }
                default -> throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_PARAMETER_PLATFORM);
            }
        }
    
        public String getDevicePushParameter(HttpServletRequest request) {
            CodeEnum.Platform platform = getDevicePlatformParameter(request);
            switch (platform) {
                case ANDROID, IOS -> {
                    String push = request.getParameter(OAuth2.PUSH_PARAM_NAME);
                    if (push == null) {
                        throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_PARAMETER_PUSH);
                    }
                    return push;
                }
                case WEB -> {
                    return null;
                }
                default -> throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_PARAMETER_PLATFORM);
            }
        }
    
        private String getDeviceAgentHeader(HttpServletRequest request) {
            String agent = request.getHeader(OAuth2.AGENT_HEADER_NAME);
            if (agent == null) {
                throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_HEADER_USER_AGENT);
            }
            return agent;
        }
    
        private String getDeviceRemoteIp(HttpServletRequest request) {
            String ip = request.getRemoteAddr();
            if (ip == null) {
                throw new PreconditionFailedException(ExceptionMessage.OAuth.INVALID_REQUEST_REMOTE_IP);
            }
            return ip;
        }
    
        public String getDeviceRedirectUriParameter(HttpServletRequest request) {
            String redirectUri = request.getParameter(OAuth2.REDIRECT_URI_PARAM_NAME);
            if (redirectUri != null) {
                validateAuthorizedRedirectUri(redirectUri);
            }
            return redirectUri;
        }
    
        private boolean validateAuthorizedRedirectUri(String redirectUri) {
            URI clientRedirectUri = URI.create(redirectUri);
    
            if (!jwtProperties.getAuthorizedRedirectUris().isEmpty()) {
                for (String authorizedUriStr : jwtProperties.getAuthorizedRedirectUris()) {
                    URI authorizedUri = URI.create(authorizedUriStr);
                    if (clientRedirectUri.getScheme().equalsIgnoreCase(authorizedUri.getScheme())
                            && clientRedirectUri.getHost().equalsIgnoreCase(authorizedUri.getHost())
                            && clientRedirectUri.getPort() == authorizedUri.getPort()) {
                        return true;
                    }
                }
                throw new PreconditionFailedException(ExceptionMessage.OAuth.UNAUTHORIZED_REDIRECT_URI);
            }
    
            return true;
        }
    }

     

     

    3. OAuth2 인가 요청을 임시로 저장하는 창고 생성

    OAuth2 로그인 플로우를 생각해보면:

    1. 사용자가 /oauth2/authorization/google 같은 엔드포인트로 이동 →
      스프링이 OAuth2AuthorizationRequest 객체(= state, redirectUri, clientId 등) 생성
    2. 이 요청 객체를 어딘가에 저장해야 합니다. (왜냐면 나중에 redirect 되고 돌아올 때 원래 요청과 비교해야 하거든요.)
    3. 사용자가 구글 로그인 후 redirect_uri로 돌아오면 →
      스프링이 저장된 요청 객체를 꺼내서 state 값 검증하고 토큰 교환 진행

    👉 이때 2번/3번을 담당하는 인터페이스가 AuthorizationRequestRepository<OAuth2AuthorizationRequest> 입니다.
    즉, OAuth2AuthorizationRequest를 저장/조회/삭제하는 역할.

     

      @Bean
        public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
            return new HttpSessionOAuth2AuthorizationRequestRepository();
        }

     

    해당 bean을 Security Config에 생성해주자

     

     

    4.저장된 AuthorizationRequest 로드 + state 검증 단계

     

    OAuth2 로그인 콜백이 들어오면:

    사용자가 구글 로그인/동의 후
    redirect_uri 로 돌아오며
    /login/oauth2/code/{registrationId}?code=...&state=... 요청이 발생합니다.

    이 요청은 OAuth2LoginAuthenticationFilter가 가로채서 처리합니다.

    처리 순서:

    • (1) AuthorizationRequestRepository 에서 저장해둔 AuthorizationRequest를 꺼냄
    • (2) 꺼내면서 세션에서 제거(remove)
    • (3) 저장된 state 와 돌아온 state 비교 → 불일치면 실패
    • (4) 일치하면 다음 단계(code → token 교환)로 넘어감

    즉,

    👉 state 검증이 통과해야만 토큰 교환 로직이 실행됩니다.

     

     

    5.인가 코드(code) → 토큰(token) 교환 단계

     

    state 검증이 끝나면,
    스프링 시큐리티가 Authorization Server(구글)로 백엔드 토큰 요청을 보냅니다.

    • 동작 컴포넌트: OAuth2AccessTokenResponseClient
    • 기본 구현체: DefaultAuthorizationCodeTokenResponseClient

    흐름:

    1. 스프링이 구글의 token endpoint 로 POST 요청
    2. code, redirect_uri, client_id, client_secret 포함
    3. 응답으로 token 묶음 수신

    구글 응답에는 보통:

    • access_token (리소스 접근용)
    • refresh_token (옵션)
    • id_token (OIDC 로그인일 때)
    • expires_in

    이 포함됩니다.

    👉 여기서부터는 브라우저가 아니라 서버가 구글과 직접 통신합니다.

     

     

     

    6.(OIDC일 경우) id_token 검증 단계

     

    구글 로그인은 OIDC를 사용하므로 id_token 이 내려옵니다.

    스프링은 자동으로:

    • id_token 서명 검증 (구글 JWK 키로)
    • iss/aud/exp/nonce 등 클레임 검증
    • 유효하지 않으면 실패 처리

    을 수행합니다.

    👉 즉 “구글이 준 로그인 신원 토큰이 진짜인가?”를 여기서 확정합니다.

     

    7.유저 정보 조회(OAuth2UserService / OidcUserService)

     

    토큰 검증까지 끝나면,
    스프링이 사용자 정보를 로딩하는 단계로 이동합니다.

    • OAuth2 방식이면: OAuth2UserService
    • OIDC 방식이면: OidcUserService

    역할:

    1. access_token 또는 id_token 기반으로
    2. userinfo endpoint를 호출하거나
    3. id_token payload에서 user identity 구성
    4. 최종적으로 OAuth2User / OidcUser 객체 생성

    👉 여기서 “구글 유저 → 우리 서비스 유저” 매핑 로직이 들어가는 지점입니다.
    (커스텀 UserService를 넣었다면 여기서 DB 조회/가입/연동 처리)

     

    8.Authentication 생성 + SecurityContext 저장

     

    사용자 객체가 준비되면 스프링 시큐리티가 인증 객체를 생성합니다.

    • 생성되는 토큰: OAuth2AuthenticationToken (또는 OIDC 토큰)
    • 이 토큰이 SecurityContext에 저장됨

    즉 서버는

    “이 요청의 사용자는 인증 완료됨”

    상태로 전환하고, 이후 요청부터는 인증된 세션으로 처리합니다.

    👉 여기까지가 “기본 OAuth2/OIDC 로그인 완성” 입니다.

     

    9. SuccessHandler 진입 + state 기반 플랫폼 분기

    인증 성공 직후,
    AuthenticationSuccessHandler(성공 핸들러)가 호출됩니다.

    여기서 네가 state에 심어둔 ClientRequest 정보를 꺼내 플랫폼 분기를 해야 합니다.

    왜냐면:

    • AuthorizationRequestRepository는 이미 remove()로 지워졌고
    • 돌아온 state는 request parameter로 그대로 남아있음
    • 너희는 state를 “왕복 컨텍스트”로 쓰는 구조임

    따라서 SuccessHandler 단계에서:

    1. request parameter의 state를 디코딩
      • Base64URL → JSON → ClientRequest 복원
    2. platform 값으로 분기

    분기 목표:

    • VSCode
      • state 컨텍스트 검증
      • OTC 발행
      • vscode redirect_uri로 otc를 붙여 리다이렉트
      • vscode가 otc로 jwt 교환
    • WEB
      • 별도 OTC 필요 없음
      • 기본 oauth2 login 성공 플로우로 마무리
      • 홈 또는 savedRequest로 redirect
      •  jwt 교환 후 Authorization 헤더 기반 접근

    👉 즉 “웹/VSCode 로그인 차이를 만드는 최종 분기 지점”은 SuccessHandler 입니다.

     

    10.최종 애플리케이션 접근

     

    SuccessHandler의 리다이렉트가 끝나면:

    • WEB:  jwt 교환 후 Authorization 헤더 기반 접근
    • VSCode: otc → jwt 교환 후 Authorization 헤더 기반 접근

    각 플랫폼이 원하는 인증 방식으로 최종 진입하게 됩니다.

    728x90
    반응형
Designed by Tistory.