
authorization 코드란 토큰을 받기 위해 사용하는 일회용 번호표 같은 것
-> 보안상 안전한 통로(Back-channel)를 사용하기 위해서
위험한 통로 (Front-channel): 사용자의 브라우저 주소창. 주소창은 로그에 남기도 하고, 누구나 쳐다볼 수 있어 보안에 취약
안전한 통로 (Back-channel): 백엔드 서버와 구글 서버가 직접 통신하는 선. 외부인은 절대 볼 수 없음
만약 구글이 토큰을 브라우저 주소창(redirecturi?access_token=...)으로 바로 쏴버리면, 해커가 이를 가로채기 너무 쉽다. 그래서 "일단 주소창으로는 금방 만료될 무의미한 '코드'만 보내줄 테니, 진짜 토큰은 너희 백엔드 서버가 나한테 직접 와서 가져가" 라고 하는 것
일회용 (One-time use): 토큰과 한 번 교환되면 그 즉시 폐기된다. 다시 사용할 수 없음
짧은 수명 (Short-lived): 보통 1분~10분 내외로 만료. 빨리 토큰으로 바꿔야 함
교환 조건: 이 코드를 토큰으로 바꾸려면 우리 서버만 알고 있는 Client Secret이 반드시 필요. 해커가 코드를 훔쳐가도 Secret이 없으면 무용지물
@GetMapping("/login/callback")
public String callback(@RequestParam String code) {
// 1. 브라우저 주소창을 통해 'code'라는 번호표를 받음
// 2. 이 code를 들고 구글 서버로 직접 달려감 (POST 요청)
// 이때 Client ID와 Client Secret을 함께 동봉함
TokenResponse tokens = authService.exchangeCodeForTokens(code);
// 3. 토큰(ID Token, Access Token)을 손에 넣음
return "redirect:/home";
}
GET https://accounts.google.com/o/oauth2/v2/auth?
client_id=MY_CLIENT_ID.apps.googleusercontent.com&
redirect_uri=https://my-app.com/login/callback&
response_type=code&
scope=openid%20profile%20email&
state=xyz123&
nonce=abc456 HTTP/1.1
- response_type=code: "나는 번호표(Code)를 원해!"라고 선언하는 것
- state: CSRF 방지용 임시 값
- nonce: 리플레이 공격 방지용으로, 나중에 ID Token에 그대로 박혀서 돌아옴
사용자 인증 및 동의: 사용자가 구글에 로그인하고 권한 승인 버튼을 누른다.
Authorization Code 발급: 구글은 사용자를 우리 서버의 redirect_uri로 돌려보내면서, 주소창에 code=...라는 임시 번호표를 얹어준다 (브라우저 노출 가능)
HTTP/1.1 302 Found
Location: https://my-app.com/login/callback?code=4/0AfgeX...&state=xyz123
POST /token HTTP/1.1
Host: oauth2.googleapis.com
Content-Type: application/x-www-form-urlencoded
code=4/0AfgeX...&
client_id=MY_CLIENT_ID.apps.googleusercontent.com&
client_secret=MY_CLIENT_SECRET&
redirect_uri=https://my-app.com/login/callback&
grant_type=authorization_code
client_secret이 여기서 처음으로 등장
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"access_token": "ya29.a0AfH6S...",
"id_token": "eyJhbGciOiJS... (매우 긴 JWT)",
"expires_in": 3599,
"token_type": "Bearer",
"refresh_token": "1//0eW3..."
}
ID Token: 드디어 서버가 뜯어볼 수 있는 사용자의 신분증이 도착
Access Token: 구글 API를 호출할 때 쓸 열쇠
| 항목 | state | nonce |
|---|---|---|
| 목적 | CSRF(사이트 간 요청 위조) 방지 | 리플레이(Replay) 공격 방지 |
| 전달 위치 | 브라우저 쿠키/세션 ↔ 인증 요청 파라미터 | 인증 요청 파라미터 ↔ ID Token 내부 |
| 검증 주체 | 백엔드 서버 (토큰 받기 전) | 백엔드 서버 (ID Token 뜯은 후) |
| 비유 | 이 답장은 내가 보낸 질문에 대한 것인가? | 이 신분증은 방금 발급된 따끈따끈한 것인가? |
1) state가 없다면? (CSRF 공격)
공격자가 자신의 code를 담은 리다이렉트 URL을 사용자에게 클릭하게 유도한다. 나의 서버는 이게 사용자가 요청한 건지 공격자가 보낸 건지 모르고 공격자의 계정을 사용자의 세션에 연결해버릴 수 있다 (사용자 계정에 공격자 계정이 연동되는 사고)
2) nonce가 없다면? (리플레이 공격)
공격자가 중간에 가로챈 ID Token을 나중에 다시 나의 서버에 제출하며 "나 로그인시켜줘!"라고 요청할 수 있다. nonce가 없다면 서버는 이게 예전에 발행된 토큰인지 방금 발행된 건지 알 길이 없다
public String generateAuthUrl(HttpSession session) {
String state = UUID.randomUUID().toString(); // 임의의 값 생성
String nonce = UUID.randomUUID().toString(); // 임의의 값 생성
// state는 세션에 저장 (나중에 callback에서 대조)
session.setAttribute("oauth_state", state);
// nonce도 세션에 저장 (나중에 ID Token과 대조)
session.setAttribute("oauth_nonce", nonce);
return "https://accounts.google.com/o/oauth2/v2/auth?"
+ "state=" + state
+ "&nonce=" + nonce
+ "...";
}
@GetMapping("/callback")
public void callback(@RequestParam String code, @RequestParam String state, HttpSession session) {
// 1. state 검증 (CSRF 방어)
String savedState = (String) session.getAttribute("oauth_state");
if (!state.equals(savedState)) {
throw new RuntimeException("CSRF 공격이 의심됩니다!");
}
// 2. 토큰 교환 후 ID Token 획득
String idToken = exchangeCodeForToken(code);
// 3. nonce 검증 (리플레이 공격 방어)
String savedNonce = (String) session.getAttribute("oauth_nonce");
String payloadNonce = extractNonceFromIdToken(idToken); // JWT Payload에서 nonce 추출
if (!savedNonce.equals(payloadNonce)) {
throw new RuntimeException("유효하지 않은 토큰입니다!");
}
}
일회성(Stateless) 처리: 서버가 여러 대(L4/L7 로드밸런싱)라면 세션 대신 Redis에 저장하거나, state 자체를 암호화된 쿠키에 담아 클라이언트에 보냈다가 다시 받는 방식을 써야 함
ID Token 내부의 nonce: nonce는 Access Token에는 없고 오직 ID Token(JWT)의 페이로드 안에만 들어있다. 따라서 OIDC 인증을 할 때만 사용할 수 있는 방어 수단
프레임워크 활용: Spring Security OAuth2 Client를 사용하면 이 과정을 내부적으로 알아서 처리해 준다. 하지만 원리를 모르면 설정 오류(예: 세션 불일치)가 났을 때 해결하기 어렵다.