init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# Keycloak Resource Server 아키텍처
|
||||
|
||||
## Why
|
||||
|
||||
auth-server 가 직접 로그인, OAuth2 callback, JWT 발급, issuer / JWK 공개를 모두 맡으면 인증 프로토콜과 비즈니스 사용자 식별이 강하게 섞입니다. 이번 구조는 인증 주체를 Keycloak 으로 옮기고, auth-server 는 검증된 access token 을 받아 내부 비즈니스 로직만 수행하는 Resource Server 로 제한합니다.
|
||||
|
||||
## What
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[Client] -->|login / token request| Keycloak[Keycloak Realm]
|
||||
Keycloak -->|broker login| Google[Google IdP]
|
||||
Keycloak -->|broker login| GitHub[GitHub IdP]
|
||||
Keycloak -->|access token| Client
|
||||
Client -->|Authorization Bearer| AuthServer[auth-server]
|
||||
AuthServer -->|JWKS fetch one-time + cached| Keycloak
|
||||
AuthServer -->|provider KEYCLOAK + sub| DB[(auth.users)]
|
||||
```
|
||||
|
||||
- Keycloak: 로그인, OAuth2 broker, issuer, token 발급, JWKS 공개를 소유합니다.
|
||||
- auth-server: Spring Security Resource Server 로 token signature, issuer, expiry 를 검증합니다.
|
||||
- application: 검증된 `sub`, `email`, `name` claim 으로 이미 연결된 내부 사용자를 식별합니다.
|
||||
- infrastructure: `provider=KEYCLOAK`, `provider_subject=sub` 기준으로 users row 를 조회합니다.
|
||||
|
||||
## How
|
||||
|
||||
### 1. ResourceServer 설정
|
||||
|
||||
[`ResourceServerSecurityConfiguration`](../../../bootstrap/src/main/java/com/project/auth/config/auth/ResourceServerSecurityConfiguration.java) 가 다음을 wiring 합니다.
|
||||
|
||||
```java
|
||||
http
|
||||
.csrf(CsrfConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**",
|
||||
"/livez", "/readyz",
|
||||
"/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasRole("user")
|
||||
.anyRequest().authenticated())
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
|
||||
.authenticationEntryPoint(securityExceptionHandler)
|
||||
.accessDeniedHandler(securityExceptionHandler));
|
||||
```
|
||||
|
||||
핵심 설정:
|
||||
- `spring.security.oauth2.resourceserver.jwt.issuer-uri=https://keycloak.dev.example.com/realms/platform`
|
||||
- 이 한 줄로 Spring Boot 가 자동으로 `JwtDecoder` Bean 을 만들고, **OIDC discovery endpoint** (`/.well-known/openid-configuration`) 에서 JWKS URI 를 조회합니다.
|
||||
- `NimbusJwtDecoder` 가 JWKS 를 가져와 캐시. 기본 캐시 TTL 5 분 (Spring Security 기본). Keycloak 키 회전이 일어나도 5 분 내 자동 반영.
|
||||
|
||||
### 2. Token 검증 체인
|
||||
|
||||
| 단계 | 검증 내용 | 실패 시 |
|
||||
|---|---|---|
|
||||
| 1 | Bearer header 형식 | 401, `WWW-Authenticate: Bearer` |
|
||||
| 2 | JWT 서명 (JWKS 공개키 매칭) | 401, `invalid_token` |
|
||||
| 3 | `iss` 가 설정된 issuer-uri 와 일치 | 401, `invalid_token` |
|
||||
| 4 | `exp` 미만료, `nbf`/`iat` 유효 | 401, `invalid_token` |
|
||||
| 5 | `aud` 가 허용 client 와 일치 (선택) | 401 |
|
||||
| 6 | `KeycloakJwtAuthenticationConverter` 가 claim → `AuthenticatedUser` 변환 | — |
|
||||
| 7 | `hasRole("user")` 권한 검사 | 403, `access_denied` |
|
||||
|
||||
1~5 는 Spring Security 가 자동, 6~7 은 본 프로젝트 코드.
|
||||
|
||||
### 3. Claim → Principal 변환
|
||||
|
||||
[`KeycloakJwtAuthenticationConverter`](../../../bootstrap/src/main/java/com/project/auth/config/auth/security/KeycloakJwtAuthenticationConverter.java) 가 다음 claim 을 프로젝트 전용 principal 로 변환합니다.
|
||||
|
||||
- `sub` → `AuthenticatedUser.subject`
|
||||
- `email` → `AuthenticatedUser.email`
|
||||
- `name` 또는 `preferred_username` → `AuthenticatedUser.name`
|
||||
- `scope` → `SCOPE_*`
|
||||
- `realm_access.roles` → `ROLE_*`
|
||||
|
||||
자세한 매핑 정책과 sample JWT payload 는 [03-claim-role-design.md](./03-claim-role-design.md).
|
||||
|
||||
### 4. 비즈니스 흐름
|
||||
|
||||
`GET /api/v1/auth/me` 는 `@CurrentUser AuthenticatedUser` 를 받아 `LoadKeycloakUserUseCase` 에 최소 claim 만 넘깁니다. 이 use case 는 `(KEYCLOAK, sub)` 로 기존 내부 사용자 id 를 조회하고, 연결된 사용자가 없으면 `AUTH-004` 404 를 반환합니다 — *자동 생성 / 자동 연결은 하지 않습니다.*
|
||||
|
||||
## Result
|
||||
|
||||
- auth-server 내부 JWT 발급기, 로컬 RSA key source, Vault Transit signer, 자체 OIDC discovery / JWKS endpoint 를 모두 제거했습니다 ([04-adr-token-ownership-cleanup.md](./04-adr-token-ownership-cleanup.md)).
|
||||
- `/api/v1/auth/login`, `/api/v1/auth/oauth2/keycloak/*`, `/oauth2/authorization/*`, `/login/oauth2/code/*` 는 더 이상 auth-server 의 로그인 경로가 아닙니다.
|
||||
- 클라이언트는 Keycloak 에서 token 을 받고 auth-server 에는 Bearer token 만 보냅니다.
|
||||
- 내부 사용자 검증은 token signature 검증이 아니라 비즈니스 식별 / 조회 문제로 분리됐습니다.
|
||||
|
||||
## 운영 고려사항
|
||||
|
||||
- **JWKS 회전**: Keycloak 측 회전 시 5 분 내 ResourceServer 가 새 키를 fetch. 회전 직후 발급된 token 이 캐시 만료 전 도달하면 `invalid_token` 가능 — Keycloak 측 grace period 또는 `NimbusJwtDecoder` cache refresh 정책 조정 가능.
|
||||
- **issuer-uri 변경**: prod 와 dev 가 서로 다른 hostname 이라 환경별 overlay 에서 주입. 이 값이 token 의 `iss` 와 한 글자라도 다르면 모든 token 이 거절됨 — 가장 흔한 운영 사고 패턴.
|
||||
- **Realm role 의존**: 기본 사용자에 `user` role 이 부여되지 않으면 401 이 아니라 *401 통과 후 403* 으로 떨어짐. 운영자는 Keycloak realm 의 default-roles-platform 설정에 `user` 가 있는지 확인해야 함.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-002: Keycloak을 인증 주체로 두고 auth-server를 Resource Server로 전환
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-17
|
||||
|
||||
## Context
|
||||
|
||||
기존 auth-server는 로컬 로그인, OAuth2 login callback, JWT 발급, issuer/JWKS 공개, Vault Transit 서명 위임까지 맡았습니다.
|
||||
Keycloak이 이미 OIDC Provider와 broker 역할을 수행할 수 있으므로, 인증 책임을 애플리케이션에 계속 남기면 보안 경계와 운영 책임이 중복됩니다.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- 로그인·회원가입·token 발급 책임을 전문 IdP로 집중한다.
|
||||
- auth-server는 비즈니스 API와 내부 사용자 식별에 집중한다.
|
||||
- DB 트랜잭션과 외부 인증 호출을 섞지 않는다.
|
||||
- 자체 JWT signer와 Vault Transit dependency를 제거한다.
|
||||
- Keycloak claim 변경의 영향 범위를 security adapter와 application command에 제한한다.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: auth-server가 계속 JWT를 발급
|
||||
|
||||
- 장점: 기존 `/auth/login`과 OAuth2 callback 흐름을 유지할 수 있습니다.
|
||||
- 단점: issuer, signing key, JWKS, Vault Transit 운영 책임이 계속 남습니다.
|
||||
- 트레이드오프: 구현 변경은 적지만 Keycloak 도입 효과가 약합니다.
|
||||
|
||||
### Option 2: Keycloak이 인증/인가 token을 발급하고 auth-server는 검증만 수행 (Keycloak-first)
|
||||
|
||||
- 장점: 로그인·회원가입·token 발급·issuer·JWKS·broker 책임이 Keycloak으로 모입니다. 내부 DB는 Keycloak token이 들어올 때 lazy upsert.
|
||||
- 단점: 클라이언트 로그인 흐름과 테스트 데이터 준비 방식이 바뀝니다. 회원가입 UX는 Keycloak 테마로 흡수해야 합니다.
|
||||
- 트레이드오프: 초기 전환 비용은 있지만 장기 운영 경계가 단순해집니다.
|
||||
|
||||
### Option 3: auth-server가 회원가입을 받고 Keycloak Admin API로 미러링
|
||||
|
||||
- 장점: 기존 signup UX를 유지할 수 있습니다.
|
||||
- 단점: 이중 쓰기 실패 시 일관성 확보용 보상 로직(outbox/Saga)이 필요하고, password 원문이 auth-server를 통과해 보안 경계가 다시 넓어집니다. `no DB transaction held across remote call` 규칙 준수 비용도 큽니다.
|
||||
- 트레이드오프: UX 유지 대가가 구조적 복잡도 증가로 직결됩니다.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2를 채택합니다. Keycloak을 인증 주체로 두고 auth-server는 Spring Security Resource Server로 token을 검증하며, 회원가입 UX와 계정 lifecycle은 Keycloak realm에 위임합니다. auth-server는 `(provider=KEYCLOAK, provider_subject=sub)` 기준으로 내부 사용자를 식별합니다. 이후 내부 사용자 생성 정책은 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에서 자동 등록으로 보강했습니다.
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- 자체 JWT 발급 코드, 로컬 signup 컨트롤러, Vault Transit signer, password hasher가 모두 제거됩니다.
|
||||
- issuer와 JWKS source of truth가 Keycloak 하나로 고정됩니다.
|
||||
- Spring Security 타입은 `bootstrap`/`presentation` 경계에서 끝나고 application은 claim command만 받습니다.
|
||||
- 내부 DB 스키마에서 `encoded_password`, LOCAL provider 분기, social subject 관련 체크 제약이 모두 제거됩니다(`V5__keycloak_only_provider.sql`).
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- 클라이언트는 더 이상 auth-server 로그인/회원가입 endpoint를 사용할 수 없습니다.
|
||||
- 회원가입 UX가 Keycloak realm 설정과 테마에 묶입니다.
|
||||
- Keycloak realm 설정은 API 인증의 필수 운영 의존성이며, 운영자는 JWKS 회전과 issuer URI 고정을 책임져야 합니다.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- `GET /api/v1/auth/me`의 token 검증은 Resource Server에 맡깁니다.
|
||||
- 연결된 내부 사용자가 없으면 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에 따라 email 충돌 검사 후 내부 사용자를 자동 등록합니다.
|
||||
- role/claim mapping은 `KeycloakJwtAuthenticationConverter` 한 곳에 둡니다.
|
||||
- 조회 흐름은 내부 DB lookup만 수행하므로 `GET`에 숨은 쓰기 부작용을 두지 않습니다.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Keycloak Claim / Role 설계
|
||||
|
||||
## Why
|
||||
|
||||
Resource Server는 token을 검증한 뒤 어떤 claim을 내부 principal로 신뢰할지 정해야 합니다.
|
||||
모든 claim을 그대로 비즈니스 계층에 넘기면 Keycloak 계약 변경이 내부 로직 전체로 번집니다.
|
||||
|
||||
## What
|
||||
|
||||
auth-server가 사용하는 최소 claim은 다음입니다.
|
||||
|
||||
| Claim | 용도 | 내부 모델 |
|
||||
|------|------|-----------|
|
||||
| `sub` | Keycloak 사용자 고정 식별자 | `provider_subject` |
|
||||
| `email` | 내부 사용자 신규 생성과 충돌 검사 | `UserEmail` |
|
||||
| `name` | 내부 사용자 표시 이름 | `UserName` |
|
||||
| `preferred_username` | `name`이 없을 때 fallback | `AuthenticatedUser.name` |
|
||||
| `scope` | OAuth2 scope authority | `SCOPE_*` |
|
||||
| `realm_access.roles` | Realm role authority | `ROLE_*` |
|
||||
|
||||
## Sample JWT payload
|
||||
|
||||
Keycloak realm `platform` 이 발급하는 access token 의 payload 는 다음 형태입니다 (값은 예시).
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://keycloak.dev.example.com/realms/platform",
|
||||
"sub": "1f7a3b2e-9c4d-4f81-a0e7-2b8f5c1d6a4b",
|
||||
"aud": "auth-server-ingress",
|
||||
"exp": 1735689600,
|
||||
"iat": 1735686000,
|
||||
"azp": "auth-server-ingress",
|
||||
"scope": "openid email profile",
|
||||
"email": "alice@example.com",
|
||||
"email_verified": true,
|
||||
"name": "Alice Kim",
|
||||
"preferred_username": "alice",
|
||||
"realm_access": {
|
||||
"roles": ["user"]
|
||||
},
|
||||
"resource_access": {
|
||||
"auth-server-ingress": { "roles": [] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`sub` 는 Keycloak 이 사용자에게 부여하는 immutable UUID 로, 내부 DB 의 `provider_subject` 와 1:1 로 묶입니다. `realm_access.roles` 는 위 표의 `ROLE_*` 매핑 대상입니다.
|
||||
|
||||
## How
|
||||
|
||||
`KeycloakJwtAuthenticationConverter`가 JWT claim을 `AuthenticatedUser`로 변환합니다.
|
||||
controller는 `@CurrentUser`로 이 principal을 받고, application에는 `KeycloakUserClaims(subject, email, name)`만 전달합니다.
|
||||
`KeycloakUserClaimsValidator`는 claim이 비어 있거나 `UserEmail`/`UserName` 도메인 규칙에 어긋나면 `InvalidKeycloakClaimsException(AUTH-003, HTTP 400)`으로 번역해 Keycloak 오염 클레임이 비즈니스 로직까지 흘러들지 않게 차단합니다.
|
||||
|
||||
권장 realm role:
|
||||
|
||||
| Keycloak role | Spring authority | 용도 |
|
||||
|---------------|------------------|------|
|
||||
| `user` | `ROLE_user` | 일반 인증 사용자 |
|
||||
| `admin` | `ROLE_admin` | 운영/관리 API |
|
||||
|
||||
역할 이름은 Keycloak realm에서 소유합니다. auth-server는 role 값을 새로 발급하거나 DB 값으로 권한을 덮어쓰지 않습니다.
|
||||
현재 `/api/v1/auth/me`는 `GET` 요청에 대해 `ROLE_user`가 필요합니다. 따라서 Keycloak realm의 self-service registration 기본 역할에는 `user`를 포함해야 하며, 역할이 없는 token은 인증은 성공하더라도 `AUTH-002`로 거절됩니다.
|
||||
|
||||
## Result / Trade-offs
|
||||
|
||||
- token 검증 결과는 신뢰하되, 내부 사용자 식별은 `(provider=KEYCLOAK, provider_subject=sub)` 기준으로만 수행합니다.
|
||||
- email은 자동 계정 연결 키로 쓰지 않습니다. 연결된 내부 사용자가 없으면 email 충돌 검사 후 새 내부 사용자를 만들고, 같은 email 이 이미 다른 subject 에 연결되어 있으면 `AUTH-005` 409를 반환합니다.
|
||||
- 실무 권장안은 “IdP subject를 immutable external identity로 삼고, email은 표시/연락/초기 등록 보조값으로만 다루는 것”입니다. email은 변경될 수 있고 재사용될 수 있으므로 권한 있는 내부 계정 연결 키로 쓰면 위험합니다.
|
||||
- 표시용 `email`/`name`은 token claim에서 왔더라도 DB에 저장된 값이 authoritative입니다. 따라서 `/api/v1/auth/me` 응답은 DB 값을 노출하고, 권한(`authorities`)은 token claim에서 그대로 가져옵니다.
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-004: Keycloak 전환 후 자체 인증 자산 일괄 제거
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-04-17 (ADR-002 와 동일 PR 묶음)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-002](./02-adr-keycloak-resource-server.md) 가 인증 주체를 Keycloak 으로 옮긴 직후, auth-server 내부에는 다음이 *기능적으로 무용해진* 상태로 남아 있었다.
|
||||
|
||||
- 자체 JWT 발급기 (`NimbusJwtTokenIssuerAdapter`, RSA key source, Vault Transit signer)
|
||||
- 로컬 회원가입 / 로그인 endpoint, BCrypt password hasher, password 도메인 규칙
|
||||
- 자체 OIDC discovery / JWKS endpoint
|
||||
- Multi-provider (LOCAL / GOOGLE / GITHUB) 분기
|
||||
|
||||
이걸 그대로 두면 *"어느 issuer 의 token 을 신뢰하는가"*, *"회원 식별자의 source of truth 는 어디인가"* 가 흐려지고, 운영 장애 가능 지점도 늘어난다 (예: 로컬 RSA key 파일 누락 시 부팅 실패, Vault Transit endpoint 변경 시 영향 등).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keycloak 으로 인증 주체가 옮겨졌으므로, auth-server 의 *모든* 인증 발급 / 키 관리 책임은 중복.
|
||||
- `provider` 컬럼이 `LOCAL/GOOGLE/GITHUB/KEYCLOAK` 4 종으로 분기되어 있으면 auth lookup 쿼리, audit event, error code 모두 분기 비용이 남는다.
|
||||
- password 가 디스크에 저장되는 한 *비밀번호 정책 / 해시 회전 / 누설 시 회전* 책임이 따라온다 — IdP 가 처리하는 게 정석.
|
||||
- 부분 제거 (deprecation 표기 후 점진 제거) 는 6~12 개월 dead code 가 portfolio 에 남는 비용이 큼.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: 점진적 deprecation (각 클래스에 `@Deprecated` + 주석)
|
||||
|
||||
- 장점: 기존 클라이언트가 일시적으로 호환됨.
|
||||
- 단점: dead code 가 PR diff 마다 노이즈가 됨. 보안 책임 (password 저장, 자체 키 보관) 이 *제거 전까지* 계속 살아 있음.
|
||||
- 트레이드오프: portfolio 관점에서 *"제거를 못 끝내는 사람"* 시그널.
|
||||
|
||||
### Option 2: 일괄 제거 + DB 스키마 정리 (V4 → V5 마이그레이션 2 단)
|
||||
|
||||
- 장점: 책임 경계가 한 PR 로 깨끗하게 정리됨. password / Vault Transit 운영 위험이 즉시 사라짐.
|
||||
- 단점: 기존 `/auth/login` / `/auth/oauth2/*` 클라이언트가 곧장 깨짐 (다만 Keycloak 으로 이미 옮겼으니 이 시점에 클라이언트는 없음).
|
||||
- 트레이드오프: 초기 변경량이 크지만, 이후 운영 표면이 작아짐.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 2 채택.** 자체 인증 발급 / 로컬 회원가입 / Vault Transit signing 자산을 일괄 제거하고, DB 스키마는 `V4__add_keycloak_provider.sql` (KEYCLOAK 값 허용) → `V5__keycloak_only_provider.sql` (encoded_password 컬럼 제거 + LOCAL/social 체크 제약 제거 + `provider` 체크를 `KEYCLOAK` 단일값으로 고정) 두 단계로 정리.
|
||||
|
||||
## 제거된 책임
|
||||
|
||||
| 이전 사용처 | 역할 | 처리 |
|
||||
|------------|------|------|
|
||||
| `AuthLoginController` | `/api/v1/auth/login` 로컬 로그인 endpoint | 제거 |
|
||||
| `AuthOAuth2Controller` | Keycloak broker 시작 / 완료 endpoint | 제거 |
|
||||
| `OAuth2SecurityConfiguration` | OAuth2 login filter chain | `ResourceServerSecurityConfiguration` 으로 대체 |
|
||||
| `UserSignUpController` / `SignUp*` | 로컬 회원가입 use case 와 DTO | 제거 (회원가입은 Keycloak 담당) |
|
||||
| `BcryptPasswordEncoderAdapter` / `PasswordHasherPort` | bcrypt 비밀번호 해싱 | 제거 (auth-server 는 password 를 다루지 않음) |
|
||||
| `EncodedPassword` / `UserPasswordPolicy` / `InvalidUserPasswordException` | 비밀번호 도메인 규칙 | 제거 |
|
||||
| `User.registerLocal` | LOCAL provider 등록 경로 | 제거 (`registerKeycloak` 만 남음) |
|
||||
| `AuthProvider.LOCAL/GOOGLE/GITHUB` | social/local provider 분기 값 | 제거 (`KEYCLOAK` 만 남음) |
|
||||
| `NimbusJwtTokenIssuerAdapter` | 자체 RS256 JWT 발급 | 제거 |
|
||||
| `JwtKeyConfiguration` / `ConfiguredJwtSigningKeySource` | 로컬 RSA signer 구성 | 제거 |
|
||||
| `VaultTransitClient` / `VaultTransitJwtSigner` | Vault Transit 서명 API 호출 | 제거 |
|
||||
| `ConfiguredOpenIdDiscoveryDocumentProvider` / `OpenIdDiscoveryController` | 자체 issuer / JWKS 공개 | 제거 |
|
||||
| `auth-login.html` | auth-server 로그인 페이지 | 제거 |
|
||||
| `AuthAuditEventType.LOGIN_*` / `OAUTH_LOGIN_*` / `TOKEN_ISSUED` / `SIGNUP_*` | 로컬 인증 / 발급 감사 이벤트 | `KEYCLOAK_USER_NOT_FOUND` 로 축소 |
|
||||
| `AuthErrorCode.INVALID_CREDENTIALS` / `OAUTH_*` | 로컬 로그인 에러 코드 | `KEYCLOAK_CLAIMS_INVALID` / `KEYCLOAK_ACCOUNT_CONFLICT` 로 교체 |
|
||||
| `UserErrorCode.*` / `ApiSuccessCode.USER_SIGNED_UP` | 로컬 회원가입 에러 / 성공 코드 | 제거 |
|
||||
|
||||
## 남은 책임
|
||||
|
||||
| 현재 사용처 | 역할 |
|
||||
|------------|------|
|
||||
| `ResourceServerSecurityConfiguration` | Keycloak issuer 기반 Bearer token 검증 |
|
||||
| `KeycloakJwtAuthenticationConverter` | claim / role → project principal 매핑 |
|
||||
| `AuthenticatedUserController` | 현재 사용자 조회 진입점 |
|
||||
| `KeycloakUserLoader` | `(KEYCLOAK, sub)` 기준 내부 사용자 식별 |
|
||||
| `KeycloakUserClaimsValidator` | 검증된 JWT 에서 올라온 claim 의 내부 도메인 적합성 확인 |
|
||||
|
||||
## DB 스키마 변화
|
||||
|
||||
- `V4__add_keycloak_provider.sql`: `KEYCLOAK` 값을 허용하는 중간 단계 migration
|
||||
- `V5__keycloak_only_provider.sql`: `encoded_password` 컬럼과 LOCAL / social 체크 제약을 제거하고, `provider_subject` 를 NOT NULL 로, `provider` 체크를 `KEYCLOAK` 단일값으로 고정
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- auth-server 는 더 이상 token 을 만들거나 공개키를 배포하거나 password 를 저장하지 않습니다.
|
||||
- issuer / JWKS source of truth 가 Keycloak 한 곳으로 고정.
|
||||
- DB 스키마에서 `encoded_password`, LOCAL provider 분기, social subject 체크 제약이 모두 제거 — 코드 분기뿐 아니라 *데이터 모델* 도 단순해짐.
|
||||
- Vault Transit endpoint 의존성이 사라져 dev 환경에서 Vault 가 secret store 역할만 하면 됨.
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- 클라이언트는 더 이상 auth-server 로그인 / 회원가입 endpoint 를 사용할 수 없음 (이 시점에 그런 클라이언트는 없었음 — pre-emptive 제거).
|
||||
- 회원가입 UX 가 Keycloak realm 설정 / 테마에 묶임.
|
||||
- Keycloak realm 설정은 API 인증의 필수 운영 의존성 — 운영자는 JWKS 회전과 issuer URI 고정을 책임.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- `GET /api/v1/auth/me` 의 token 검증은 ResourceServer 가 담당.
|
||||
- 연결된 내부 사용자가 없으면 [ADR-005](./05-adr-keycloak-user-auto-registration.md)에 따라 email 충돌 검사 후 내부 사용자 자동 등록.
|
||||
- role / claim mapping 은 `KeycloakJwtAuthenticationConverter` 한 곳에 모임.
|
||||
- 조회 흐름은 내부 DB lookup 만 수행하므로 `GET` 에 숨은 쓰기 부작용 없음.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [02-adr-keycloak-resource-server.md](./02-adr-keycloak-resource-server.md) — 인증 주체 이전 결정 (ADR-002)
|
||||
- [05-adr-keycloak-user-auto-registration.md](./05-adr-keycloak-user-auto-registration.md) — Keycloak 인증 사용자 내부 자동 등록 결정 (ADR-005)
|
||||
- [01-architecture.md](./01-architecture.md) — 현재 ResourceServer 아키텍처
|
||||
- [03-claim-role-design.md](./03-claim-role-design.md) — claim / role 매핑 정책
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-005: Keycloak 인증 사용자 내부 자동 등록
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-05-12
|
||||
|
||||
## Context
|
||||
|
||||
Keycloak 이 로그인과 계정 lifecycle 을 소유하면 auth-server 는 검증된 access token 으로 내부 사용자 레코드를 식별해야 합니다. 기존 결정은 내부 사용자가 없으면 `AUTH-004` 404 를 반환하는 방식이었지만, 이 방식은 Keycloak self-service registration 과 auth-server 의 내부 사용자 테이블을 별도 운영 절차로 동기화해야 했습니다.
|
||||
|
||||
자동 등록을 도입하더라도 email 을 계정 연결 키로 쓰면 안 됩니다. email 은 변경되거나 재사용될 수 있으므로 내부 권한 식별자는 계속 Keycloak `sub` 와 `(provider=KEYCLOAK, provider_subject=sub)` 조합이어야 합니다.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keycloak self-service registration 이후 첫 API 호출에서 내부 사용자 레코드를 준비한다.
|
||||
- 인증 주체와 token 발급 책임은 계속 Keycloak 에 둔다.
|
||||
- email 은 충돌 검사에만 사용하고 내부 계정 연결 키로 사용하지 않는다.
|
||||
- 내부 사용자 생성은 application use case 의 트랜잭션 경계 안에서 수행한다.
|
||||
- Resource Server 부팅은 Keycloak discovery endpoint 가 떠 있어야만 성공하는 구조를 피한다.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option 1: 내부 사용자가 없으면 계속 404 반환
|
||||
|
||||
- 장점: `GET /api/v1/auth/me` 가 순수 조회로 남고 쓰기 부작용이 없다.
|
||||
- 단점: Keycloak 사용자와 내부 사용자 테이블을 별도 배치나 운영 절차로 맞춰야 한다.
|
||||
- 트레이드오프: HTTP 의미는 단순하지만 운영 동기화 비용이 생긴다.
|
||||
|
||||
### Option 2: 첫 인증 요청에서 내부 사용자 자동 등록
|
||||
|
||||
- 장점: Keycloak 에서 계정을 만든 사용자가 첫 API 호출 시 바로 내부 사용자 레코드를 얻는다.
|
||||
- 단점: `GET /api/v1/auth/me` 가 missing user 경로에서 쓰기를 수행한다.
|
||||
- 트레이드오프: 사용자 온보딩은 단순해지지만 transaction, 충돌 처리, 감사 이벤트가 필요하다.
|
||||
|
||||
### Option 3: Keycloak Admin/Event API 로 사전 동기화
|
||||
|
||||
- 장점: API 요청 경로의 쓰기 부작용을 줄일 수 있다.
|
||||
- 단점: 외부 API 호출, retry, idempotency, 실패 보상 흐름이 필요하다.
|
||||
- 트레이드오프: 운영 복잡도가 커지고 Keycloak 이벤트 전달 신뢰성에 의존한다.
|
||||
|
||||
## Decision
|
||||
|
||||
Option 2 를 채택합니다. `GET /api/v1/auth/me` 는 검증된 Keycloak claim 의 `sub` 로 내부 사용자를 조회하고, 없으면 `email` 충돌을 먼저 검사한 뒤 내부 사용자를 자동 등록합니다. 동일 email 이 이미 다른 subject 에 연결되어 있으면 자동 연결하지 않고 `AUTH-005` 409 를 반환합니다.
|
||||
|
||||
## Consequences
|
||||
|
||||
### 긍정적 결과
|
||||
|
||||
- Keycloak self-service registration 과 내부 사용자 생성이 첫 API 호출에서 자연스럽게 이어진다.
|
||||
- 내부 사용자 식별 기준은 여전히 immutable `sub` 이며, email 기반 자동 연결은 금지된다.
|
||||
- 자동 등록 성공은 `KEYCLOAK_USER_AUTO_REGISTERED` 감사 이벤트로 남는다.
|
||||
|
||||
### 부정적 결과
|
||||
|
||||
- `GET /api/v1/auth/me` 는 missing user 경로에서 DB write 를 수행한다.
|
||||
- 동시 첫 요청에서는 DB unique 제약과 충돌 처리 정책을 함께 고려해야 한다.
|
||||
- 운영 환경은 `APP_SECURITY_KEYCLOAK_ISSUER_URI` 를 제공해야 한다.
|
||||
|
||||
### 위험 완화
|
||||
|
||||
- application use case 에 transaction boundary 를 둔다.
|
||||
- 같은 email 이 이미 존재하면 `AUTH-005` 409 로 중단하고 새 subject 에 자동 연결하지 않는다.
|
||||
- `(provider, provider_subject)` unique 제약으로 subject 중복 생성을 방지한다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Keycloak
|
||||
|
||||
Keycloak 을 OAuth2 / OIDC Provider 겸 Identity Broker 로 두고 auth-server 를 ResourceServer 로 축소한 구조의 결정 기록과 설계 문서.
|
||||
|
||||
## 현재 프로젝트 맥락
|
||||
|
||||
- auth-server 는 Keycloak realm 이 발급한 access token 을 ResourceServer 로 검증한다.
|
||||
- 로그인, OAuth2 broker, token 발급, issuer / JWKS 공개 책임은 Keycloak 이 가진다.
|
||||
- auth-server 는 검증된 claim 을 바탕으로 내부 사용자를 식별하고, 처음 보는 Keycloak `sub` 는 email 충돌 검사 후 내부 사용자로 자동 등록한다.
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [01-architecture.md](./01-architecture.md) | Keycloak 중심 인증 구조 + ResourceServer 검증 체인 + JWKS 캐시 / issuer-uri 운영 |
|
||||
| [02-adr-keycloak-resource-server.md](./02-adr-keycloak-resource-server.md) | 인증 주체를 Keycloak 으로 이전한 ADR-002 |
|
||||
| [03-claim-role-design.md](./03-claim-role-design.md) | Keycloak claim / role 설계 + sample JWT payload |
|
||||
| [04-adr-token-ownership-cleanup.md](./04-adr-token-ownership-cleanup.md) | 자체 JWT / Vault Transit / 로컬 회원가입 일괄 제거 ADR-004 |
|
||||
| [05-adr-keycloak-user-auto-registration.md](./05-adr-keycloak-user-auto-registration.md) | Keycloak 인증 사용자 내부 자동 등록 ADR-005 |
|
||||
|
||||
## 역할 분리
|
||||
|
||||
- 심화 설계 문서: 이 폴더
|
||||
- 로컬 설정 절차: [docs/development/keycloak/LOCAL_SETUP.md](../../development/keycloak/LOCAL_SETUP.md)
|
||||
- 로컬 realm import 자산: [docs/development/keycloak/realm/project-auth-realm-local.json](../../development/keycloak/realm/project-auth-realm-local.json)
|
||||
Reference in New Issue
Block a user