이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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-sessionHTTP/1.1 302 Found
Location: /melevel=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-sessionHTTP/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=S256POST /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_tokenquery 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=55a0b03clevel=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이 남지 않는지 확인한다.
openidscope가 없는 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가 담당하기 때문이다.