142 lines
6.5 KiB
Markdown
142 lines
6.5 KiB
Markdown
---
|
|
id: a3493786-d3fb-4b01-b1c5-ecb23c3d5497
|
|
kind: CONCEPT
|
|
slug: forward-auth-and-auth-request
|
|
title: Forward-Auth와 Nginx auth_request의 동작
|
|
topic: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 전
|
|
version: 4
|
|
basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request module
|
|
studio: "https://hyeonworks.com/studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit"
|
|
---
|
|
|
|
# Forward-Auth와 Nginx auth_request의 동작
|
|
|
|
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
|
|
|
|
## 관계
|
|
|
|
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
|
이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한 기준이다.
|
|
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
|
헤더 위조를 실제로 재현한 기록이다.
|
|
- **OAuth/OIDC 인증 패턴 선택 기준**
|
|
이 구조를 언제 고르는지 비교한 기준이다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## 요청 하나가 두 번 평가된다
|
|
|
|
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. `location /`에 다음 directive가 있다.
|
|
|
|
```nginx label="general location의 auth_request"
|
|
auth_request /oauth2/auth;
|
|
```
|
|
|
|
Nginx는 먼저 `/oauth2/auth`로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
|
|
|
|
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있고 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
|
|
|
|
## subrequest가 실어 보내는 것
|
|
|
|
subrequest는 body를 보내지 않고 `Content-Length`를 비운다. 원래 요청의 문맥은 헤더로 바뀐다.
|
|
|
|
| subrequest 헤더 | 값의 출처 |
|
|
|---|---|
|
|
| `X-Original-URL` | scheme, host와 original request URI |
|
|
| `X-Real-IP` | client address |
|
|
| `X-Forwarded-For` | proxy chain |
|
|
| `X-Forwarded-Host` | original host |
|
|
| `X-Forwarded-Proto` | original scheme |
|
|
| `X-Forwarded-Uri` | original request URI |
|
|
| `Cookie` | 브라우저에 cookie가 있을 때 원래 요청의 값 |
|
|
|
|
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
|
|
|
|
## 미인증 401의 응답이 경로마다 다르다
|
|
|
|
인증 결과가 401일 때 무엇을 돌려줄지는 location마다 다르다.
|
|
|
|
| 외부 입력 | 인증 상태 | 결과 |
|
|
|---|---|---|
|
|
| `GET /` | 미인증 | `/oauth2/start`로 302 |
|
|
| `GET /api/edge` | 미인증 | `Location` 없는 401 JSON |
|
|
|
|
general location은 `@oauth2_signin`으로 이동해 로그인을 시작한다.
|
|
|
|
```http label="미인증 navigation의 응답"
|
|
HTTP/1.1 302 Found
|
|
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
|
|
```
|
|
|
|
exact API location은 redirect 없이 401을 만든다. 브라우저 UX와 프로그램이 부르는 API UX를 나눈 구성이다. 이 분리는 해당 path에만 구성돼 있고 다른 path는 general location 규칙을 따른다.
|
|
|
|
## 인증 결과를 변수로 옮긴다
|
|
|
|
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 `auth_request_set`으로 그 값을 local 변수에 복사한다.
|
|
|
|
```text label="auth_request_set 변수"
|
|
$auth_user ← oauth2-proxy X-Auth-Request-User
|
|
$auth_email ← oauth2-proxy X-Auth-Request-Email
|
|
$auth_cookie ← oauth2-proxy Set-Cookie
|
|
```
|
|
|
|
## upstream 요청을 새로 만든다
|
|
|
|
원래 요청을 그대로 전달하지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
|
|
|
|
```http label="Nginx가 만드는 upstream 요청"
|
|
GET http://app:8081/edge/me
|
|
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
|
|
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
|
|
X-Internal-Auth-Token: <nginx-environment-secret>
|
|
```
|
|
|
|
client가 보낸 같은 이름의 헤더를 merge하지 않고 덮어쓴다. 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 upstream 입력은 oauth2-proxy가 확인한 실제 user가 된다.
|
|
|
|
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더는 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝나야 한다.
|
|
|
|
## upstream은 두 겹을 확인한다
|
|
|
|
Spring controller는 헤더 두 개를 함께 본다.
|
|
|
|
```text label="/edge/me의 확인 순서"
|
|
1. X-Auth-Request-User가 blank인지 확인
|
|
2. X-Internal-Auth-Token을 읽는다
|
|
3. 설정된 token과 MessageDigest.isEqual로 비교
|
|
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
|
|
```
|
|
|
|
`MessageDigest.isEqual`은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
|
|
|
|
user 헤더가 없거나 internal token이 틀리면 401이다.
|
|
|
|
```json label="신뢰 조건을 만족하지 못한 응답"
|
|
{
|
|
"error": "trusted edge authentication is required"
|
|
}
|
|
```
|
|
|
|
이 검사는 Spring Security의 `/edge/**` rule이 아니라 controller가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
|
|
|
|
## 세 방어선이 각각 막는 것
|
|
|
|
```text label="AP4가 사용하는 세 방어선"
|
|
network isolation : app 8081과 oauth2-proxy 4180을 host에 publish하지 않는다
|
|
header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴다
|
|
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
|
|
```
|
|
|
|
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
|
|
|
|
## 지금 구성이 보여 주지 않는 것
|
|
|
|
general `location /`도 `proxy_pass http://app:8081/edge/me`를 쓴다. `/orders/123` 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
|
|
|
|
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.
|
|
|
|
<!-- body:end -->
|