이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • OAuth2의 네 가지 역할은 각각 무엇을 책임지는가?
  • Authorization Code Flow에서 브라우저를 거치는 값과 서버끼리 교환하는 값은 어떻게 다른가?
  • Spring OAuth2 Login 설정만 보고도 code flow의 보안 경계를 어떻게 점검할 수 있는가?

개요

OAuth2는 사용자가 비밀번호를 제3자 애플리케이션에 직접 주지 않고 제한된 접근 권한을 위임하게 하는 프레임워크다. OAuth2 자체는 “로그인 프로토콜”이 아니라 authorization framework다.

로그인으로 쓰려면 provider가 사용자를 인증했다는 결과를 애플리케이션이 검증해야 한다. 이 표준화된 계층이 OIDC이며, ID Token과 UserInfo가 여기에서 등장한다.

Authorization Code Flow는 현대 웹 애플리케이션의 기본 흐름이다. 브라우저는 Authorization Server로 이동했다가 code를 들고 client의 redirect endpoint로 돌아오고, client 서버는 token endpoint와 back channel로 token을 교환한다.

이 문서의 핵심은 “OAuth 설정이 켜져 있다”가 아니라 “각 역할과 channel에서 어떤 값을 신뢰할 수 있는가”다.

공격 시나리오

  • 공격자는 로그인 시작 URL과 callback URL의 parameter를 관찰한다.
  • redirect callback에 다른 flow의 code를 끼워 넣어 code injection을 시도한다.
  • client가 Authorization Server와 Resource Server를 혼동하면 provider profile 응답을 내부 권한으로 오해하게 만든다.
  • authorization code가 로그나 analytics에 남으면 짧은 시간 안에 token 교환을 시도한다.
  • state 검증이 약하면 피해자의 브라우저에서 공격자가 시작한 flow를 완료하게 만든다.
  • authorization code flow를 이해하지 못하면 token을 브라우저에 노출하는 implicit-style 구현으로 퇴행하기 쉽다.

취약한 요청/응답 예시

GET /login/oauth2/code/google?code=attacker-code HTTP/1.1
Host: app.example.com
Cookie: SESSION=victim-session
HTTP/1.1 302 Found
Location: /me
level=INFO event=oauth_callback path=/login/oauth2/code/google?code=attacker-code session=victim-session

문제는 callback이 어떤 로그인 시작 요청에 대응하는지 확인하지 않고, code 원문까지 로그에 남긴다는 점이다. code는 짧게 살아도 token으로 바뀔 수 있는 민감값이다.

주제별 핵심 판단

  • Resource Owner는 사용자, Client는 우리 애플리케이션, Authorization Server는 provider의 인가 서버, Resource Server는 provider API다.
  • authorization endpoint는 브라우저 front channel이고 token endpoint는 서버 back channel이다.
  • Authorization code는 access token이 아니라 token 교환을 위한 일회성 grant다.
  • Client는 callback에서 state와 등록된 redirect_uri 흐름을 확인해야 한다.
  • OIDC 로그인에서는 token 교환 뒤 ID Token 검증이 필요하다.
  • access token은 provider Resource Server 호출 권한이지 내부 사용자 권한이 아니다.
  • code, access token, refresh token은 URL, referer, access log, error log에 남기지 않는다.

개선된 요청/응답 예시

GET /oauth2/authorization/google HTTP/1.1
Host: app.example.com
Cookie: SESSION=user-session
HTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=client-123&scope=openid%20profile%20email&state=RANDOM_STATE&redirect_uri=https%3A%2F%2Fapp.example.com%2Flogin%2Foauth2%2Fcode%2Fgoogle&code_challenge=...&code_challenge_method=S256
POST /token HTTP/1.1
Host: accounts.google.com
Content-Type: application/x-www-form-urlencoded
 
grant_type=authorization_code&code=...&redirect_uri=https%3A%2F%2Fapp.example.com%2Flogin%2Foauth2%2Fcode%2Fgoogle&code_verifier=...

브라우저에는 authorization request와 authorization response가 지나간다. token 교환은 서버가 provider token endpoint로 직접 수행한다.

방어 설계

  • 애플리케이션이 OAuth2 Client인지 Resource Server인지 Authorization Server인지 역할을 분리한다.
  • 로그인 시작 시 authorization request를 세션 또는 안전한 저장소에 연결한다.
  • callback에서는 state, registrationId, redirect endpoint, code 사용 여부를 확인한다.
  • token endpoint 호출은 서버에서 수행하고 client secret 또는 client authentication을 안전하게 다룬다.
  • OIDC provider라면 ID Token 검증 결과를 내부 사용자 식별로 사용한다.
  • provider access token은 내부 session id와 같은 값으로 취급하지 않는다.
  • 실패 이벤트는 provider, registrationId, reason, traceId를 포함해 남긴다.
  • 로그 필터에서 code, access_token, refresh_token, id_token query parameter와 form field를 마스킹한다.

Spring/HTTP 예시

@Bean
SecurityFilterChain oauthClientSecurity(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login", "/oauth2/**", "/login/oauth2/**").permitAll()
            .anyRequest().authenticated())
        .oauth2Login(oauth -> oauth
            .authorizationEndpoint(endpoint -> endpoint
                .baseUri("/oauth2/authorization"))
            .redirectionEndpoint(endpoint -> endpoint
                .baseUri("/login/oauth2/code/*")))
        .build();
}
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email

설정은 흐름을 활성화한다. 리뷰에서는 redirect URI 등록, state 저장소, PKCE 적용 여부, ID Token 검증 결과, token 로그 마스킹을 함께 확인해야 한다.

운영 로그와 감사 지점

level=INFO event=oauth_authorization_started result=redirect provider=google registrationId=google sessionHash=6f18 redirectUri=https://app.example.com/login/oauth2/code/google traceId=55a0b03c
level=WARN event=oauth_callback_rejected result=blocked provider=google registrationId=google reason=state_mismatch sessionHash=6f18 traceId=55a0b03c
  • authorization code와 token 원문은 로그에 남기지 않는다.
  • callback 실패는 state_mismatch, unknown_registration, code_reuse, token_exchange_failed처럼 reason을 구분한다.
  • provider와 registrationId를 남겨 여러 provider 운영 시 원인을 분리한다.
  • 로그인 성공 로그는 provider subject hash와 내부 member id를 연결해 남기되 token은 제외한다.
  • access log query string 마스킹이 실제로 동작하는지 운영 로그 샘플로 검증한다.

실전 판단 기준

  • OAuth2 설정을 보면 먼저 애플리케이션의 역할을 묻는다.
  • front channel에 token이 노출되는 흐름이면 현대 웹 로그인으로는 부적절하다.
  • code는 access token이 아니지만 access token으로 바뀔 수 있으므로 secret처럼 취급한다.
  • oauth2Login이 있다고 해서 social account linking 정책이 안전해지는 것은 아니다.
  • provider access token으로 내부 API 인가를 결정하면 권한 모델이 섞인다.
  • OAuth2 장애 대응은 provider 장애, 사용자의 동의 철회, 내부 session 문제를 구분해야 한다.

테스트 포인트

  • callback에 state가 없거나 다른 state일 때 로그인이 거부되는지 확인한다.
  • 다른 provider registrationId에 발급된 code를 넣었을 때 거부되는지 확인한다.
  • code 재사용 시도가 token 교환 실패와 보안 로그로 남는지 확인한다.
  • access log와 error log에 code와 token이 남지 않는지 확인한다.
  • openid scope가 없는 provider 설정으로 로그인 신뢰 수준을 어떻게 다루는지 확인한다.
  • provider 장애 시 내부 session이 부분 생성되지 않는지 확인한다.

위험 신호!

  • OAuth2를 “소셜 로그인 API” 정도로만 설명하고 역할 구분이 없다.
  • callback에서 state 검증 실패가 성공 흐름과 같은 로그로 남는다.
  • authorization code가 query string 포함 access log에 그대로 남는다.
  • access token을 브라우저 localStorage에 저장한다.
  • provider access token을 내부 API bearer token처럼 재사용한다.
  • 로그인 성공 직후 내부 계정 생성/연결 정책이 코드에 명확히 보이지 않는다.

확인 질문

확인 질문

  • OAuth2 네 가지 역할은 무엇인가?
    • Resource Owner, Client, Authorization Server, Resource Server다.
  • Authorization Code Flow에서 token 교환은 어디에서 일어나야 하는가?
    • client 서버와 Authorization Server token endpoint 사이의 back channel에서 일어나야 한다.
  • OAuth2만으로 로그인이라고 말하기 어려운 이유는 무엇인가?
    • OAuth2는 권한 위임 프레임워크이고 사용자 인증 결과 검증은 OIDC 같은 identity layer가 담당하기 때문이다.

참고 문서