init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# Keycloak 로컬 설정
|
||||
|
||||
이 문서는 로컬 개발 환경에서 Keycloak을 띄우고 연결하는 **runbook**입니다.
|
||||
Keycloak의 아키텍처, Identity Broker 패턴, 프로젝트 적용 배경은 [docs/topics/03-keycloak/README.md](../../topics/03-keycloak/README.md)를 참고합니다.
|
||||
|
||||
## 기본 전제
|
||||
|
||||
- `docker compose`는 `deploy/docker/docker-compose.yml`을 사용합니다.
|
||||
- 실제 소셜 로그인용 `Google`/`GitHub` Client ID와 Secret은 auth-server가 아니라 Keycloak에 등록합니다.
|
||||
- **회원가입과 로그인 UX는 Keycloak realm이 담당합니다.** auth-server는 Keycloak이 발급한 token을 검증만 하며, signup/login endpoint나 password 저장을 가지지 않습니다.
|
||||
- auth-server는 Keycloak issuer를 Resource Server 설정으로 사용하고, token 발급 client secret을 보관하지 않습니다.
|
||||
|
||||
## 1. 로컬 인프라 실행
|
||||
|
||||
`.env.local` 값을 현재 환경에 맞게 확인한 뒤 아래 명령으로 실행합니다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.local up -d
|
||||
```
|
||||
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- Keycloak: `http://localhost:8081`
|
||||
|
||||
## 2. Keycloak 초기 상태
|
||||
|
||||
처음 기동하면 아래 내용이 자동으로 import 됩니다.
|
||||
|
||||
- realm: `project-auth`
|
||||
- client: `project-auth-server`
|
||||
|
||||
위 값은 로컬 개발 기준 고정 예시입니다. 운영이나 공유 환경에서는 환경값에 맞춰 다시 설정해야 합니다.
|
||||
|
||||
## 3. Realm 회원가입(Self-service registration) 활성화
|
||||
|
||||
auth-server는 회원가입을 받지 않으므로 Keycloak realm의 self-service registration을 켭니다.
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Realm Settings → Login` 탭
|
||||
3. `User registration`: **ON**
|
||||
4. 필요 시 `Email as username`, `Verify email`, `Forgot password` 활성화
|
||||
5. `Authentication → Password policy`에서 최소 길이/복잡도 정책 설정
|
||||
|
||||
결과: Keycloak의 로그인 화면에 "Register" 링크가 노출되고, 사용자는 Keycloak에서 직접 계정을 만듭니다.
|
||||
|
||||
## 4. Google Identity Provider 등록
|
||||
|
||||
Keycloak Admin Console에서 아래 순서로 진행합니다.
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Identity providers`
|
||||
3. `Google` 추가
|
||||
4. alias를 `google`로 설정
|
||||
5. Google Cloud Console에서 발급한 Client ID / Client Secret 입력
|
||||
6. Keycloak이 보여주는 Redirect URI를 Google OAuth 설정에 등록
|
||||
|
||||
## 5. GitHub Identity Provider 등록
|
||||
|
||||
1. `project-auth` realm 선택
|
||||
2. `Identity providers`
|
||||
3. `GitHub` 추가
|
||||
4. alias를 `github`로 설정
|
||||
5. GitHub OAuth App에서 발급한 Client ID / Client Secret 입력
|
||||
6. Keycloak이 보여주는 Redirect URI를 GitHub OAuth 설정에 등록
|
||||
|
||||
## 6. Realm role / token claim 점검
|
||||
|
||||
auth-server는 다음 claim을 신뢰합니다(상세: [03-claim-role-design.md](../../topics/03-keycloak/03-claim-role-design.md)).
|
||||
|
||||
- `sub`, `email`, `name`, `preferred_username`
|
||||
- `realm_access.roles` (Spring authority `ROLE_*`)
|
||||
- `scope` (Spring authority `SCOPE_*`)
|
||||
|
||||
점검 항목:
|
||||
|
||||
1. `Realm roles`에서 `user`, `admin` 생성
|
||||
2. `Realm Settings → User registration`의 기본 역할에 `user` 포함
|
||||
3. `Client scopes → roles → Mappers`에 `realm roles` mapper가 활성화되어 있는지 확인 (기본 제공)
|
||||
4. `Client scopes → email` / `profile`이 project-auth-server client의 default client scope에 들어 있는지 확인
|
||||
|
||||
## 7. auth-server와 맞춰야 하는 값
|
||||
|
||||
auth-server의 로컬 설정은 아래 값을 기준으로 Keycloak과 연결됩니다.
|
||||
|
||||
- issuer: `http://localhost:8081/realms/project-auth`
|
||||
- Spring 설정: `spring.security.oauth2.resourceserver.jwt.issuer-uri`
|
||||
|
||||
즉 auth-server는 Keycloak token의 `iss`와 realm JWKS를 검증합니다. 소셜 로그인 분기는 Keycloak 또는 프론트엔드 로그인 흐름에서 처리합니다.
|
||||
|
||||
## 8. auth-server 실행
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source .env.local
|
||||
set +a
|
||||
|
||||
./gradlew :bootstrap:bootRun
|
||||
```
|
||||
|
||||
## 9. 인증된 호출 예시
|
||||
|
||||
Keycloak에서 access token을 받은 뒤 auth-server에는 Bearer token으로 요청합니다.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST \
|
||||
"http://localhost:8081/realms/project-auth/protocol/openid-connect/token" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=project-auth-server" \
|
||||
-d "username=<kc-user>" \
|
||||
-d "password=<kc-password>" \
|
||||
-d "scope=openid profile email" | jq -r .access_token)
|
||||
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/auth/me
|
||||
```
|
||||
|
||||
첫 호출 시 auth-server가 token의 `sub`로 내부 DB에 사용자 행을 lazy-create하고, 두 번째 호출부터는 기존 내부 식별자를 재사용합니다.
|
||||
같은 email 이 이미 다른 Keycloak subject 에 연결되어 있으면 auth-server는 자동 연결하지 않고 `AUTH-005` / `409 Conflict`를 반환합니다.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"realm": "project-auth",
|
||||
"enabled": true,
|
||||
"displayName": "Project Auth",
|
||||
"sslRequired": "NONE",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "project-auth-server",
|
||||
"name": "project-auth-server",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": false,
|
||||
"secret": "project-auth-server-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"baseUrl": "http://localhost:8080",
|
||||
"redirectUris": [
|
||||
"http://localhost:3000/*",
|
||||
"http://localhost:8080/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:8080"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "7f87f966-14d8-4ea0-bd6f-2a41d657aef2",
|
||||
"name": "project-auth-server resource-server",
|
||||
"description": "Keycloak access token 검증과 연결된 내부 사용자 조회 확인용 Postman 컬렉션",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "현재 사용자 조회",
|
||||
"request": {
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"bearer": [
|
||||
{
|
||||
"key": "token",
|
||||
"value": "{{keycloakAccessToken}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v1/auth/me",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
},
|
||||
"description": "Keycloak access token을 검증하고 이미 연결된 내부 users row를 provider=KEYCLOAK, provider_subject=sub 기준으로 조회합니다."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('200 OK 반환', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('현재 사용자 응답 확인', function () {",
|
||||
" var json = pm.response.json();",
|
||||
" pm.expect(json.success).to.eql(true);",
|
||||
" pm.expect(json.code).to.eql('AUTHENTICATED_USER_LOADED');",
|
||||
" pm.expect(json.data.provider).to.eql('KEYCLOAK');",
|
||||
" pm.expect(json.data.subject).to.be.a('string').and.not.empty;",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bearer token 없음",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/v1/auth/me",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v1",
|
||||
"auth",
|
||||
"me"
|
||||
]
|
||||
},
|
||||
"description": "Authorization 헤더가 없을 때 resource server가 401을 반환하는지 확인합니다."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('401 Unauthorized 반환', function () {",
|
||||
" pm.response.to.have.status(401);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('인증 필요 에러 코드 확인', function () {",
|
||||
" var json = pm.response.json();",
|
||||
" pm.expect(json.success).to.eql(false);",
|
||||
" pm.expect(json.code).to.eql('AUTH-001');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8080"
|
||||
},
|
||||
{
|
||||
"key": "keycloakAccessToken",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user