diff --git a/AGENTS.md b/AGENTS.md index 08fb792..e1452b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ Aho-Corasick 알고리즘 기반의 한국어 비속어 필터링 REST API 서 ``` profanity-api (Presentation, Boot JAR) ── domain + storage:rdb + storage:redis 의존 ├─ presentation/ REST Controllers - ├─ security/ API Key 인증 (filter / authentication / aspect / annotation) - ├─ application/ Async 이벤트 리스너, EmailService, HttpClient(클라이언트 IP/referrer 추출) + ├─ security/ API Key / 로그인 JWT 인증, SSO handler, refresh cookie·CSRF + ├─ application/ 로그인 orchestration, Async 이벤트 리스너, EmailService, HttpClient ├─ web/response/ 응답 meta 커스터마이징 (ResponseBodyAdvice 기반) ├─ exception/ GlobalExceptionHandler └─ config/ Aspect / LocalCache(Caffeine) / Mail @@ -30,8 +30,9 @@ profanity-domain (Business Logic, 라이브러리) ── shared 를 api() 로 ├─ application/filter/ NormalProfanityFilter(Aho-Corasick), DefaultProfanityHandler ├─ application/manage/ SyncScheduler, DailyReportScheduler, Word/Report/Sync 서비스 ├─ application/client/ ClientsCommandService, MetadataReader, APIKeyGenerator + ├─ application/auth/ SSO 계정 upsert, 교환 코드, refresh session rotation ├─ application/event/ FilterEvent / AsyncFilterEvent / TrackingRecorder - └─ domain/ 엔티티(Clients, ProfanityWord, Report, Records) + Repository 포트 + └─ domain/ 엔티티(Clients, User/OAuthAccount, LoginSession, ProfanityWord 등) + Repository 포트 profanity-storage:rdb (Data Access - RDB) └─ domain 의 Repository 포트를 Spring Data JPA(Jpa*Repository)로 구현 @@ -74,9 +75,13 @@ profanity-shared (Common) - **[갭] 적용 조건이 `body instanceof ApiResponse` 인데, 메인 필터 엔드포인트는 `FilterApiResponse`(별도 record, `ApiResponse` 아님)를 반환하므로 `meta`가 붙지 않음.** 현재 `ApiResponse` 반환 경로(clients 등)에만 적용됨 ### 인증 체계 (`profanity-api/.../security`) -- `X-API-KEY` 헤더 기반 Stateless Spring Security -- `CustomAuthenticationFilter`(OncePerRequestFilter) → `AuthenticationService`가 메타데이터 조회 후 권한을 `ROLE_` 접두사로 변환 → `SecurityContext` -- `ExcludePath` enum으로 필터 제외 경로 관리 (clients/register, send-email, health, ping, resource). actuator/관측 스택은 제거됨 +- Stateless Spring Security에서 `API_KEY`, `LOGIN_JWT`, 미래 확장용 `OAUTH2_ACCESS_TOKEN`을 명시적으로 분리 +- `CustomAuthenticationFilter` → `RequestCredentialResolver` → 타입별 authenticator가 정확히 하나의 `Authentication`만 새 `SecurityContext`에 설정 +- 기존 외부 API는 `X-API-KEY`와 `AUTH_API_KEY`; `/api/v1/auth/me`, `/api/v1/dashboard/**`는 RS256 로그인 JWT와 `AUTH_LOGIN_JWT`/`ROLE_USER` 사용 +- OAuth2 Client Credentials access token은 의도적으로 미구현. 외부 API Bearer는 `OAUTH2_ACCESS_TOKEN` 경계에서 HTTP 401/code 4017로 fail-closed +- SSO 성공은 일회용 교환 코드 → `/api/v1/auth/exchange`; access token 15분, opaque refresh 14일/절대 세션 30일, MySQL hash 저장과 rotation 사용 +- refresh replay는 5초 grace 안에서 loser 요청만 실패하고 family를 유지하며, grace 이후 재사용은 session family 전체 폐기 +- `ExcludePath` enum으로 public/자체 검증 경로를 관리하고 refresh는 HttpOnly cookie와 CSRF로 보호 - `@VerifiedClientOnly` + `ClientVerificationAspect`(`@Around @Order(1)`): BLOCK/DISCARD 권한 클라이언트를 403으로 차단 - 권한(`PermissionsType`): READ / WRITE / DELETE / BLOCK / DISCARD (기본 [READ]) @@ -101,6 +106,10 @@ profanity-shared (Common) | POST | `/api/v1/word/accept/{requestId}` | 단어 요청 승인 (WRITE 권한) | | GET | `/api/v1/sync?password=...` | 수동 동기화 (관리자) | | GET | `/api/v1/health`, `/api/v1/ping` | 헬스 체크 | +| POST | `/api/v1/auth/exchange` | SSO 일회용 코드를 access/refresh token으로 교환 | +| GET | `/api/v1/auth/csrf` | refresh 요청용 CSRF token 조회 | +| POST | `/api/v1/auth/refresh` | refresh token rotation | +| GET | `/api/v1/auth/me` | LOGIN_JWT 사용자 조회 | - 응답은 대부분 HTTP 200이며, 비즈니스 결과는 `status.code`로 전달. 전체 코드는 `StatusCode` enum과 `/overview.md`의 Error Model 기준 diff --git a/README.md b/README.md index 8979e3c..e8d5467 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ > - https://api.kr-filter.com/api/v1/health - 기존 `x-api-key` 신규 발급은 중단 예정이며, 이미 발급된 키는 호환성 유지를 위해 유지합니다. -- 신규 사용자는 Google/GitHub SSO 기반 대시보드에서 API 클라이언트를 발급하고, OAuth2 Client Credentials 기반 Bearer 인증으로 전환할 예정입니다. +- Google/GitHub SSO 로그인용 JWT access token과 rotating refresh token을 지원합니다. +- OAuth2 Client Credentials 기반 외부 API Bearer 인증은 아직 미구현이며, 향후 대시보드의 API 클라이언트 발급 기능과 함께 제공할 예정입니다. ## Overview @@ -48,9 +49,9 @@ ### 인증 전환 방향 -현재 API 호출은 `x-api-key` 헤더를 사용합니다. 신규 인증 모델은 SSO 로그인으로 API 클라이언트를 발급한 뒤 `client_id`와 `client_secret`으로 `/oauth2/token`에서 access token을 발급받아 `Authorization: Bearer {access_token}`으로 호출하는 방식입니다. +현재 외부 API 호출은 기존 `x-api-key` 헤더를 사용합니다. 사람의 대시보드 로그인은 Google/GitHub SSO 완료 후 발급되는 `LOGIN_JWT`와 rotating refresh token을 사용하며, 외부 API 인증과 분리되어 있습니다. -전환 기간에는 기존 `x-api-key`와 신규 Bearer token을 모두 지원합니다. 대시보드 접근은 Google/GitHub SSO 세션만 허용하고, 외부 API 호출 인증과 분리합니다. +OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API용 Bearer access token은 다음 단계의 범위입니다. 현재 외부 API에 제출된 Bearer token은 지원되지 않는 `OAUTH2_ACCESS_TOKEN` 경계에서 fail-closed 처리하며, 기존 API Key 동작은 유지합니다. 상세 계약은 [Authentication](profanity-api/src/main/resources/openapi/authentication.md)을 참고하세요. - [ADR 0005. SSO 기반 사용자 계정 모델 도입](docs/adr/0005%20SSO%20기반%20사용자%20계정%20모델%20도입.md) - [ADR 0006. OAuth2 Client Credentials 기반 API 인증 전환](docs/adr/0006%20OAuth2%20Client%20Credentials%20기반%20API%20인증%20전환.md) @@ -87,10 +88,19 @@ | 4001 | Invalid Callback URL | 콜백 URL 형식이 올바르지 않은 경우 발생합니다. | | 4002 | Invalid Tracking ID | Tracking ID가 유효하지 않은 경우 발생합니다. | | 4003 | Not Fount Tracking ID | Tracking ID를 찾을 수 없는 경우 발생합니다. | +| 4004 | Ambiguous Credentials | 다중 또는 중복 인증 정보를 제출한 경우 발생합니다. | | 4010 | Unauthorized | 요청을 인증할 API 키 값이 없는 경우 발생하는 오류 입니다. | +| 4011 | OAuth2 Login Failed | Google/GitHub SSO 로그인에 실패한 경우 발생합니다. | +| 4012 | Login Code Invalid | 로그인 교환 코드가 잘못됐거나 만료 또는 재사용된 경우 발생합니다. | +| 4013 | Login Token Invalid | 로그인 access token 검증에 실패한 경우 발생합니다. | +| 4014 | Login Token Expired | 로그인 access token이 만료된 경우 발생합니다. | +| 4015 | Refresh Token Invalid | refresh token 또는 session이 잘못됐거나 만료·폐기된 경우 발생합니다. | +| 4016 | Refresh Token Reused | 이미 소비된 refresh token이 다시 제출된 경우 발생합니다. | +| 4017 | OAuth2 Token Unsupported | 외부 API용 OAuth2 access token이 아직 지원되지 않는 경우 발생합니다. | | 4030 | Forbidden | 서버에서 요청에 API 키값을 인식하였으나 해당 키가 적절한 권한을 가지지 않았다고 판정한 경우 발생합니다. | | 4031 | Not Found Client | API Key에 해당하는 클라이언트 정보를 찾을 수 없는 경우 발생합니다. | | 4032 | Invalid API Key | API Key가 유효하지 않은 경우 발생합니다. | +| 4033 | User Inactive | 로그인 사용자가 비활성 상태인 경우 발생합니다. | | 4290 | Too Many Requests | 특정 클라이언트가 너무 많은 요청을 단위 시간 안에 보낸 경우에 이 응답이 리턴됩니다. | | 5000 | Internal Server Error | 서버 측의 문제로 요청에 대한 처리가 불가능한 경우 오류가 발생하였음을 알리기 위해 본 코드를 사용합니다. | | 5030 | Service Unavailable | 서비스 점검 또는 일시 사용 불가 상태를 의미합니다. | diff --git "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" index ced0a42..747c461 100644 --- "a/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" +++ "b/docs/adr/0005 SSO \352\270\260\353\260\230 \354\202\254\354\232\251\354\236\220 \352\263\204\354\240\225 \353\252\250\353\215\270 \353\217\204\354\236\205.md" @@ -1,7 +1,7 @@ # 5. SSO 기반 사용자 계정 모델 도입 ## Status -제안 (2026.06.30) +채택 (2026.07.11) ## Context 현재 신규 클라이언트 등록은 `POST /api/v1/clients/register` 요청만으로 API Key를 즉시 발급한다. 입력값은 이름, 이메일, 발급자 정보, 메모 수준이며, 이메일 소유 증명이나 사용자 로그인 주체가 없다. @@ -18,22 +18,37 @@ - 신규 사용자는 Google 또는 GitHub OAuth2 로그인으로만 가입한다. - 내부 사용자 모델은 `users`로 표현하고, 외부 로그인 연결은 `oauth_accounts`로 분리한다. -- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다. +- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다. `primary_email`은 필수이며 lowercase 정규화와 binary 비교로 사용자 간 중복을 허용하지 않는다. - `oauth_accounts`는 provider, provider user id, provider email, email verified 상태처럼 외부 로그인 식별 정보를 저장한다. - 한 사용자가 Google과 GitHub를 모두 연결할 수 있도록 `users`와 `oauth_accounts`는 1:N 관계로 둔다. +- 새로운 provider 로그인의 검증된 이메일이 기존 `users.primary_email`과 같고 provider가 현재 소유권을 신뢰할 수 있으면 새 사용자를 만들지 않고 해당 사용자에 provider 계정을 연결한다. +- GitHub는 `/user/emails` 응답에서 `primary=true`이면서 `verified=true`인 이메일만 대표 이메일로 사용한다. 이 조건을 만족하는 이메일이 없으면 로그인을 완료하지 않는다. +- Google은 `email_verified=true`이면서 `@gmail.com` 또는 서명된 `hd` claim이 있는 이메일만 사용자 계정 생성과 로그인에 사용한다. - 기존 `clients.email`은 로그인 계정으로 승격하지 않고, 기존 키 claim 및 연락용 legacy email로 취급한다. - 외부 공개 API용 OAuth2 Client Credentials 토큰 발급과 신규 API Key 정책은 이 ADR에서 결정하지 않고 별도 ADR에서 다룬다. ## Implementation Scope -이번 0005 적용은 Google과 GitHub SSO 앱 등록, Spring OAuth2 client registration, authorization 진입점, callback URL, success/failure handler로 이어지는 콜백 파이프라인을 파악하고 검증하는 데 집중한다. +현재 구현은 다음 범위까지 포함한다. -대시보드용 서버 로그인 토큰 발급, claim 할당, 기존 API Key 연결, 사용자 생성 및 `oauth_accounts` upsert 처리 로직은 다음 구현 단계에서 다룬다. +- `(provider, provider_user_id)`와 검증된 대표 이메일 기준의 race-safe `users` 생성 및 `oauth_accounts` 연결 +- Google의 검증된 이메일과 GitHub `/user/emails`의 primary·verified 이메일만 필수 대표 이메일로 사용하고, 현재 소유권을 신뢰할 수 있는 같은 이메일의 provider 계정만 한 사용자에 연결하는 정책 +- provider email만으로 legacy client를 자동 연결하지 않는 정책 +- OAuth2 callback 성공 후 60초 수명의 일회용 교환 코드를 URL fragment로 전달하고 `POST /api/v1/auth/exchange`에서 한 번만 소비하는 흐름 +- `users.id`를 subject로 사용하는 15분 RS256 대시보드 access token +- MySQL에 원문 대신 SHA-256 hash를 저장하는 14일 rotating refresh token과 30일 절대 세션 +- 동시 refresh 중 하나만 성공시키고, 5초 grace 이후 소비된 token 재사용 시 session family를 폐기하는 replay 정책 +- `GET /api/v1/auth/me`와 `/api/v1/dashboard/**`를 `LOGIN_JWT` 전용 경계로 분리 + +기존 API Key claim과 기존 사용자의 자동 마이그레이션은 포함하지 않는다. 외부 API용 OAuth2 Client Credentials도 ADR 0006의 후속 구현 범위로 남긴다. ## Consequences -- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드용 stateless 인증 토큰과 대시보드 API 호출 인증은 SSO 이후 단계에서 별도로 구현한다. +- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드는 stateless `LOGIN_JWT`를 사용하고 기존 외부 API는 legacy API Key 계약을 유지한다. - 로컬 비밀번호 저장과 비밀번호 재설정 기능을 만들지 않아도 된다. - 대시보드에서 “내 계정”, “외부 로그인 연결”, “기존 키 연결” 같은 사용자 중심 기능을 제공할 수 있다. -- GitHub는 이메일 비공개 또는 미검증 케이스가 있으므로 provider email만으로 기존 키 소유권을 자동 확정하면 안 된다. +- GitHub primary·verified 이메일과 Google의 Gmail·Workspace 검증 이메일이 같으면 하나의 내부 사용자에 두 provider를 연결할 수 있다. +- 비-Gmail이고 `hd`가 없는 Google 외부 이메일은 현재 소유권을 Google이 보장하지 않으므로 사용자 계정 생성과 로그인을 완료하지 않는다. +- GitHub에서 primary·verified 이메일을 제공하지 않으면 내부 사용자를 생성하거나 로그인을 완료할 수 없다. +- provider email이 검증됐더라도 그 값만으로 기존 키 소유권을 자동 확정하면 안 된다. - 기존 사용자는 SSO 로그인 후 기존 API Key와 이메일 인증을 통해 기존 키를 claim하는 별도 마이그레이션 흐름이 필요하다. - 운영자는 기존 사용자에게 전환 안내 메일을 보내고, 미전환 사용자를 추적할 수 있어야 한다. diff --git "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" index 35ec537..00942d8 100644 --- "a/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" +++ "b/docs/adr/0006 OAuth2 Client Credentials \352\270\260\353\260\230 API \354\235\270\354\246\235 \354\240\204\355\231\230.md" @@ -28,6 +28,15 @@ - 외부 API 엔드포인트는 전환 기간 동안 Bearer token과 legacy `x-api-key`를 모두 허용한다. - 대시보드 엔드포인트는 SSO 세션만 허용하고, legacy `x-api-key`로 접근할 수 없게 한다. +## Implementation Status + +이 ADR은 아직 제안 상태이며 OAuth2 Client Credentials는 구현하지 않았다. + +- `/oauth2/token`, `client_id/client_secret` 발급·저장, Authorization Server는 존재하지 않는다. +- 인증 타입에는 미래 확장 경계인 `OAUTH2_ACCESS_TOKEN`만 정의한다. +- 외부 API에 제출된 Bearer token은 현재 HTTP `401`과 business code `4017`로 fail-closed 처리하며 API Key나 로그인 JWT로 fallback하지 않는다. +- 구현된 Bearer 인증은 사람의 대시보드 접근을 위한 `LOGIN_JWT`이며, 본 ADR의 외부 API access token과 다른 credential이다. + ## Consequences - 신규 API 인증은 OAuth2 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다. - `client_id/client_secret`은 토큰 발급에만 사용하고, 실제 API 호출에는 짧은 수명의 access token을 사용한다. diff --git a/module.secrets b/module.secrets index 5934a0a..12158cb 160000 --- a/module.secrets +++ b/module.secrets @@ -1 +1 @@ -Subproject commit 5934a0af1ee195dad8f64ff5aec3534fb499a6f2 +Subproject commit 12158cb6f89b2afe2597d886bb767c012ad7e7f7 diff --git a/profanity-api/build.gradle b/profanity-api/build.gradle index c4126a1..2baff8c 100644 --- a/profanity-api/build.gradle +++ b/profanity-api/build.gradle @@ -17,6 +17,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.security:spring-security-oauth2-jose' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-aop' diff --git a/profanity-api/src/main/java/app/application/auth/LoginAuthService.java b/profanity-api/src/main/java/app/application/auth/LoginAuthService.java new file mode 100644 index 0000000..b5073c9 --- /dev/null +++ b/profanity-api/src/main/java/app/application/auth/LoginAuthService.java @@ -0,0 +1,163 @@ +package app.application.auth; + +import app.core.data.response.constant.StatusCode; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import app.security.jwt.IssuedAccessToken; +import app.security.jwt.LoginJwtService; +import app.security.login.LoginFlowException; +import app.security.login.LoginSessionProperties; +import app.security.login.SecureOpaqueTokenService; +import app.security.login.SecureOpaqueTokenService.OpaqueToken; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LoginAuthService implements SsoLoginCompletionService { + private final SsoAccountService ssoAccountService; + private final LoginExchangeCodeService exchangeCodeService; + private final LoginRefreshTokenService refreshTokenService; + private final UserAccountRepository userAccountRepository; + private final SecureOpaqueTokenService opaqueTokenService; + private final LoginJwtService loginJwtService; + private final LoginSessionProperties properties; + private final Clock loginAuthClock; + + @Override + public String issueExchangeCode(OAuthLoginProfile profile) { + Instant now = loginAuthClock.instant(); + UserAccount userAccount = ssoAccountService.upsert(profile, now); + OpaqueToken exchangeCode = opaqueTokenService.generate(); + exchangeCodeService.issue( + userAccount.getId(), exchangeCode.hash(), now, properties.exchangeCodeTtl()); + return exchangeCode.plaintext(); + } + + @Transactional + public LoginTokenBundle exchange(String plaintextCode) { + Instant now = loginAuthClock.instant(); + LoginExchangeCodeConsumeResult result; + try { + result = exchangeCodeService.consume(opaqueTokenService.hash(plaintextCode), now); + } catch (IllegalArgumentException exception) { + throw invalidLoginCode(); + } + if (!result.isConsumed()) { + throw switch (result.status()) { + case USER_INACTIVE -> inactiveUser(false); + case INVALID, EXPIRED, ALREADY_CONSUMED -> invalidLoginCode(); + case CONSUMED -> new IllegalStateException("Consumed exchange code has no user"); + }; + } + + UserAccount userAccount = requireActiveUser(result.userId(), false); + OpaqueToken refreshToken = opaqueTokenService.generate(); + var session = + refreshTokenService.createSession( + userAccount.getId(), + refreshToken.hash(), + now, + properties.refreshTokenTtl(), + properties.absoluteSessionTtl()); + IssuedAccessToken accessToken = loginJwtService.issue(userAccount); + return bundle(accessToken, refreshToken, session.tokenExpiresAt(), userAccount, now); + } + + @Transactional + public LoginTokenBundle refresh(String plaintextRefreshToken) { + Instant now = loginAuthClock.instant(); + OpaqueToken replacement = opaqueTokenService.generate(); + LoginRefreshRotationResult result; + try { + result = + refreshTokenService.rotate( + opaqueTokenService.hash(plaintextRefreshToken), + replacement.hash(), + now, + properties.refreshTokenTtl(), + properties.refreshReuseGrace()); + } catch (IllegalArgumentException exception) { + throw invalidRefreshToken(true); + } + + if (!result.isRotated()) { + throw switch (result.status()) { + case REUSED_WITHIN_GRACE -> reusedRefreshToken(false); + case REUSE_DETECTED_SESSION_REVOKED -> reusedRefreshToken(true); + case USER_INACTIVE_SESSION_REVOKED -> inactiveUser(true); + case INVALID_TOKEN, TOKEN_EXPIRED, SESSION_EXPIRED, SESSION_REVOKED -> + invalidRefreshToken(true); + case ROTATED -> new IllegalStateException("Rotated refresh token has no user"); + }; + } + + UserAccount userAccount = requireActiveUser(result.userId(), true); + IssuedAccessToken accessToken = loginJwtService.issue(userAccount); + return bundle(accessToken, replacement, result.replacementExpiresAt(), userAccount, now); + } + + public UserAccount currentUser(UUID userId) { + return requireActiveUser(userId, false); + } + + private LoginTokenBundle bundle( + IssuedAccessToken accessToken, + OpaqueToken refreshToken, + Instant refreshExpiresAt, + UserAccount userAccount, + Instant now) { + long accessExpiresIn = Math.max(1, Duration.between(now, accessToken.expiresAt()).toSeconds()); + Duration refreshMaxAge = Duration.between(now, refreshExpiresAt); + return new LoginTokenBundle( + accessToken.token(), accessExpiresIn, refreshToken.plaintext(), refreshMaxAge, userAccount); + } + + private UserAccount requireActiveUser(UUID userId, boolean expireRefreshCookie) { + UserAccount userAccount = + userAccountRepository + .findById(userId) + .orElseThrow(() -> invalidRefreshToken(expireRefreshCookie)); + if (!userAccount.isActive()) { + throw inactiveUser(expireRefreshCookie); + } + return userAccount; + } + + private LoginFlowException invalidLoginCode() { + return new LoginFlowException(StatusCode.LOGIN_CODE_INVALID, HttpStatus.UNAUTHORIZED); + } + + private LoginFlowException invalidRefreshToken(boolean expireCookie) { + return new LoginFlowException( + StatusCode.REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED, expireCookie); + } + + private LoginFlowException reusedRefreshToken(boolean expireCookie) { + return new LoginFlowException( + StatusCode.REFRESH_TOKEN_REUSED, HttpStatus.UNAUTHORIZED, expireCookie); + } + + private LoginFlowException inactiveUser(boolean expireCookie) { + return new LoginFlowException(StatusCode.USER_INACTIVE, HttpStatus.FORBIDDEN, expireCookie); + } + + public record LoginTokenBundle( + String accessToken, + long accessExpiresIn, + String refreshToken, + Duration refreshMaxAge, + UserAccount userAccount) { + @Override + public String toString() { + return "LoginTokenBundle[tokens=redacted, userId=" + userAccount.getId() + "]"; + } + } +} diff --git a/profanity-api/src/main/java/app/application/auth/SsoLoginCompletionService.java b/profanity-api/src/main/java/app/application/auth/SsoLoginCompletionService.java new file mode 100644 index 0000000..3fc76eb --- /dev/null +++ b/profanity-api/src/main/java/app/application/auth/SsoLoginCompletionService.java @@ -0,0 +1,8 @@ +package app.application.auth; + +import app.domain.user.OAuthLoginProfile; + +@FunctionalInterface +public interface SsoLoginCompletionService { + String issueExchangeCode(OAuthLoginProfile profile); +} diff --git a/profanity-api/src/main/java/app/config/OpenApiConfig.java b/profanity-api/src/main/java/app/config/OpenApiConfig.java index d658f37..e7e375e 100644 --- a/profanity-api/src/main/java/app/config/OpenApiConfig.java +++ b/profanity-api/src/main/java/app/config/OpenApiConfig.java @@ -3,18 +3,27 @@ import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.security.SecuritySchemes; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -@SecurityScheme( - name = "ApiKeyAuth", - type = SecuritySchemeType.APIKEY, - in = SecuritySchemeIn.HEADER, - paramName = "x-api-key", - description = "클라이언트 등록 후 발급받은 API Key") +@SecuritySchemes({ + @SecurityScheme( + name = "ApiKeyAuth", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = "x-api-key", + description = "클라이언트 등록 후 발급받은 API Key"), + @SecurityScheme( + name = "LoginJwtAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "Google/GitHub SSO 로그인 후 발급된 대시보드 access token") +}) public class OpenApiConfig { @Bean diff --git a/profanity-api/src/main/java/app/dto/request/AuthCodeExchangeRequest.java b/profanity-api/src/main/java/app/dto/request/AuthCodeExchangeRequest.java new file mode 100644 index 0000000..74abc95 --- /dev/null +++ b/profanity-api/src/main/java/app/dto/request/AuthCodeExchangeRequest.java @@ -0,0 +1,10 @@ +package app.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AuthCodeExchangeRequest(@NotBlank String code) { + @Override + public String toString() { + return "AuthCodeExchangeRequest[code=redacted]"; + } +} diff --git a/profanity-api/src/main/java/app/dto/response/CsrfTokenResponse.java b/profanity-api/src/main/java/app/dto/response/CsrfTokenResponse.java new file mode 100644 index 0000000..ce09bf2 --- /dev/null +++ b/profanity-api/src/main/java/app/dto/response/CsrfTokenResponse.java @@ -0,0 +1,8 @@ +package app.dto.response; + +public record CsrfTokenResponse(String headerName, String token) { + @Override + public String toString() { + return "CsrfTokenResponse[headerName=" + headerName + ", token=redacted]"; + } +} diff --git a/profanity-api/src/main/java/app/dto/response/LoginTokenResponse.java b/profanity-api/src/main/java/app/dto/response/LoginTokenResponse.java new file mode 100644 index 0000000..4814090 --- /dev/null +++ b/profanity-api/src/main/java/app/dto/response/LoginTokenResponse.java @@ -0,0 +1,15 @@ +package app.dto.response; + +public record LoginTokenResponse( + String accessToken, String tokenType, long expiresIn, LoginUserResponse user) { + @Override + public String toString() { + return "LoginTokenResponse[accessToken=redacted, tokenType=" + + tokenType + + ", expiresIn=" + + expiresIn + + ", userId=" + + user.id() + + "]"; + } +} diff --git a/profanity-api/src/main/java/app/dto/response/LoginUserResponse.java b/profanity-api/src/main/java/app/dto/response/LoginUserResponse.java new file mode 100644 index 0000000..c523f4e --- /dev/null +++ b/profanity-api/src/main/java/app/dto/response/LoginUserResponse.java @@ -0,0 +1,5 @@ +package app.dto.response; + +import java.util.UUID; + +public record LoginUserResponse(UUID id, String displayName, String email, String avatarUrl) {} diff --git a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java index cd25212..f22cb2f 100644 --- a/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java +++ b/profanity-api/src/main/java/app/exception/GlobalExceptionHandler.java @@ -4,8 +4,11 @@ import app.core.data.response.Status; import app.core.data.response.constant.StatusCode; import app.core.exception.BusinessException; +import app.security.authentication.CredentialAuthenticationException; +import app.security.login.LoginFlowException; import java.util.Objects; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; @@ -36,6 +39,20 @@ public ResponseEntity> handleBadCredentialsException( return ApiResponse.error(Status.of(resolve)); } + @ExceptionHandler(LoginFlowException.class) + public ResponseEntity> handleLoginFlowException(LoginFlowException ex) { + log.warn("로그인 인증 예외 발생: {}", ex.getStatusCode().name()); + return ApiResponse.error(ex.getHttpStatus(), Status.of(ex.getStatusCode())); + } + + @ExceptionHandler(CredentialAuthenticationException.class) + public ResponseEntity> handleCredentialAuthenticationException( + CredentialAuthenticationException ex) { + StatusCode statusCode = StatusCode.resolve(ex.getMessage()); + log.warn("요청 인증 예외 발생: {}", statusCode.name()); + return ApiResponse.error(ex.httpStatus(), Status.of(statusCode)); + } + @ExceptionHandler(AuthenticationException.class) public ResponseEntity> handleAuthenticationException( AuthenticationException ex) { @@ -46,9 +63,8 @@ public ResponseEntity> handleAuthenticationException( @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { - log.warn("접근 권한 예외 발생: {}", ex.getMessage()); - StatusCode resolve = StatusCode.resolve(ex.getMessage()); - return ApiResponse.error(Status.of(resolve)); + log.warn("접근 권한 예외 발생: {}", ex.getClass().getSimpleName()); + return ApiResponse.error(HttpStatus.FORBIDDEN, Status.of(StatusCode.FORBIDDEN)); } @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/profanity-api/src/main/java/app/openapi/AuthOpenApi.java b/profanity-api/src/main/java/app/openapi/AuthOpenApi.java new file mode 100644 index 0000000..be59ec8 --- /dev/null +++ b/profanity-api/src/main/java/app/openapi/AuthOpenApi.java @@ -0,0 +1,393 @@ +package app.openapi; + +import app.core.data.response.Status; +import app.dto.request.AuthCodeExchangeRequest; +import app.dto.response.CsrfTokenResponse; +import app.dto.response.LoginTokenResponse; +import app.dto.response.LoginUserResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public final class AuthOpenApi { + private static final String TOKEN_RESPONSE_EXAMPLE = + """ + { + "status": { + "code": 2000, + "message": "Ok", + "description": "정상적으로 처리 되었습니다.", + "DetailDescription": "" + }, + "data": { + "accessToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImxvZ2luLWtleS0xIn0.example.signature", + "tokenType": "Bearer", + "expiresIn": 900, + "user": { + "id": "018f4fd8-9f6f-7d1a-9b80-3f1f8dd7c001", + "displayName": "홍길동", + "email": "user@example.com", + "avatarUrl": "https://example.com/avatar.png" + } + } + } + """; + + private static final String USER_RESPONSE_EXAMPLE = + """ + { + "status": { + "code": 2000, + "message": "Ok", + "description": "정상적으로 처리 되었습니다.", + "DetailDescription": "" + }, + "data": { + "id": "018f4fd8-9f6f-7d1a-9b80-3f1f8dd7c001", + "displayName": "홍길동", + "email": "user@example.com", + "avatarUrl": "https://example.com/avatar.png" + } + } + """; + + private static final String INVALID_LOGIN_CODE_EXAMPLE = + """ + { + "status": { + "code": 4012, + "message": "Login_code_invalid", + "description": "로그인 교환 코드가 유효하지 않습니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + private static final String INVALID_EXCHANGE_REQUEST_EXAMPLE = + """ + { + "status": { + "code": 4000, + "message": "Bad_request", + "description": "처리에 실패하였습니다. 요청이 잘못 되었거나 필수 파라미터가 누락된 경우 발생 합니다. Description에서 보다 상세한 오류 메세지를 확인할 수 있습니다.", + "DetailDescription": "code: must not be blank / " + }, + "data": null + } + """; + + private static final String INVALID_REFRESH_TOKEN_EXAMPLE = + """ + { + "status": { + "code": 4015, + "message": "Refresh_token_invalid", + "description": "로그인 refresh token이 유효하지 않습니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + private static final String REUSED_REFRESH_TOKEN_EXAMPLE = + """ + { + "status": { + "code": 4016, + "message": "Refresh_token_reused", + "description": "이미 사용한 refresh token이 다시 제출되었습니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + private static final String INVALID_LOGIN_TOKEN_EXAMPLE = + """ + { + "status": { + "code": 4013, + "message": "Login_token_invalid", + "description": "로그인 access token이 유효하지 않습니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + private static final String FORBIDDEN_EXAMPLE = + """ + { + "status": { + "code": 4030, + "message": "Forbidden", + "description": "인증 권한이 부적절합니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + private static final String INACTIVE_USER_EXAMPLE = + """ + { + "status": { + "code": 4033, + "message": "User_inactive", + "description": "비활성 사용자 계정입니다.", + "DetailDescription": "" + }, + "data": null + } + """; + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @Tag(name = "Authentication", description = "SSO 로그인 JWT와 refresh token 관리 API") + public @interface ApiTag {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "로그인 코드 교환", + description = + """ + OAuth2 로그인 성공 후 frontend redirect URI로 전달된 일회용 code를 로그인 token으로 교환합니다. + code는 한 번만 사용할 수 있으며, 성공하면 access token은 응답 body로, refresh token은 HttpOnly cookie로 발급합니다. + """, + requestBody = + @RequestBody( + required = true, + description = "OAuth2 로그인 완료 후 발급받은 일회용 교환 코드", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AuthCodeExchangeRequest.class), + examples = + @ExampleObject( + name = "exchangeCode", + summary = "일회용 로그인 코드 교환", + value = "{\"code\":\"sso_exchange_code_example\"}"))), + responses = { + @ApiResponse( + responseCode = "200", + description = + "access token 발급 및 refresh cookie 설정. 요청 검증 실패는 status.code 4000으로 반환됩니다.", + headers = + @Header( + name = HttpHeaders.SET_COOKIE, + description = "HttpOnly refresh token cookie", + schema = @Schema(type = "string")), + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = LoginTokenApiResponse.class), + examples = { + @ExampleObject(name = "success", value = TOKEN_RESPONSE_EXAMPLE), + @ExampleObject( + name = "invalidRequest", + summary = "code 누락 또는 공백", + value = INVALID_EXCHANGE_REQUEST_EXAMPLE) + })), + @ApiResponse( + responseCode = "401", + description = "교환 코드가 유효하지 않거나 만료 또는 이미 소비됨", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = + @ExampleObject(name = "invalidCode", value = INVALID_LOGIN_CODE_EXAMPLE))), + @ApiResponse( + responseCode = "403", + description = "연결된 사용자 계정이 비활성 상태임", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = + @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE))) + }) + public @interface Exchange {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "CSRF token 조회", + description = + """ + refresh 요청에 사용할 CSRF token을 발급합니다. + 응답의 headerName과 token을 refresh 요청 header에 넣고, 함께 발급된 XSRF-TOKEN cookie도 전송해야 합니다. + """, + responses = + @ApiResponse( + responseCode = "200", + description = "CSRF header 이름과 token 반환 및 XSRF-TOKEN cookie 설정", + headers = + @Header( + name = HttpHeaders.SET_COOKIE, + description = "CSRF 검증용 XSRF-TOKEN cookie", + schema = @Schema(type = "string")), + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CsrfTokenApiResponse.class), + examples = + @ExampleObject( + name = "csrfToken", + value = + """ + { + "status": { + "code": 2000, + "message": "Ok", + "description": "정상적으로 처리 되었습니다.", + "DetailDescription": "" + }, + "data": { + "headerName": "X-XSRF-TOKEN", + "token": "csrf_token_example" + } + } + """)))) + public @interface Csrf {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "로그인 token 갱신", + description = + """ + refresh token을 rotate하고 새 access token과 refresh token을 발급합니다. + 먼저 GET /api/v1/auth/csrf를 호출한 뒤 PF_LOGIN_REFRESH 및 XSRF-TOKEN cookie와 X-XSRF-TOKEN header를 함께 전송해야 합니다. + 이미 사용한 refresh token을 재사용하면 보안 정책에 따라 요청 또는 token family가 거부됩니다. + """, + parameters = { + @Parameter( + name = "PF_LOGIN_REFRESH", + in = ParameterIn.COOKIE, + required = true, + description = "HttpOnly refresh token cookie. 브라우저가 자동으로 전송합니다.", + schema = @Schema(type = "string")), + @Parameter( + name = "XSRF-TOKEN", + in = ParameterIn.COOKIE, + required = true, + description = "GET /api/v1/auth/csrf에서 발급된 CSRF cookie", + schema = @Schema(type = "string")), + @Parameter( + name = "X-XSRF-TOKEN", + in = ParameterIn.HEADER, + required = true, + description = "GET /api/v1/auth/csrf 응답의 data.token 값", + schema = @Schema(type = "string")) + }, + responses = { + @ApiResponse( + responseCode = "200", + description = "새 access token 발급 및 refresh cookie 교체", + headers = + @Header( + name = HttpHeaders.SET_COOKIE, + description = "rotate된 HttpOnly refresh token cookie", + schema = @Schema(type = "string")), + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = LoginTokenApiResponse.class), + examples = @ExampleObject(name = "success", value = TOKEN_RESPONSE_EXAMPLE))), + @ApiResponse( + responseCode = "401", + description = "refresh token이 무효·만료되었거나 이미 사용됨", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = { + @ExampleObject(name = "invalidToken", value = INVALID_REFRESH_TOKEN_EXAMPLE), + @ExampleObject(name = "reusedToken", value = REUSED_REFRESH_TOKEN_EXAMPLE) + })), + @ApiResponse( + responseCode = "403", + description = "CSRF 검증 실패 또는 사용자 계정 비활성", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = { + @ExampleObject(name = "csrfRejected", value = FORBIDDEN_EXAMPLE), + @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE) + })) + }) + public @interface Refresh {} + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Operation( + summary = "로그인 사용자 조회", + description = "Authorization: Bearer 으로 인증된 현재 로그인 사용자를 반환합니다.", + responses = { + @ApiResponse( + responseCode = "200", + description = "현재 로그인 사용자 정보", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = LoginUserApiResponse.class), + examples = + @ExampleObject(name = "currentUser", value = USER_RESPONSE_EXAMPLE))), + @ApiResponse( + responseCode = "401", + description = "access token이 누락·무효·만료됨", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = + @ExampleObject( + name = "invalidToken", + value = INVALID_LOGIN_TOKEN_EXAMPLE))), + @ApiResponse( + responseCode = "403", + description = "사용자 계정이 비활성 상태임", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorApiResponse.class), + examples = + @ExampleObject(name = "inactiveUser", value = INACTIVE_USER_EXAMPLE))) + }, + security = @SecurityRequirement(name = "LoginJwtAuth")) + public @interface Me {} + + @Schema(name = "LoginTokenApiResponse", description = "로그인 token 발급 응답") + public record LoginTokenApiResponse( + Status status, LoginTokenResponse data, Map meta) {} + + @Schema(name = "CsrfTokenApiResponse", description = "CSRF token 발급 응답") + public record CsrfTokenApiResponse( + Status status, CsrfTokenResponse data, Map meta) {} + + @Schema(name = "LoginUserApiResponse", description = "로그인 사용자 조회 응답") + public record LoginUserApiResponse( + Status status, LoginUserResponse data, Map meta) {} + + @Schema(name = "AuthErrorApiResponse", description = "로그인 인증 오류 응답") + public record ErrorApiResponse(Status status, Object data, Map meta) {} +} diff --git a/profanity-api/src/main/java/app/presentation/AuthController.java b/profanity-api/src/main/java/app/presentation/AuthController.java new file mode 100644 index 0000000..5d0e7cb --- /dev/null +++ b/profanity-api/src/main/java/app/presentation/AuthController.java @@ -0,0 +1,121 @@ +package app.presentation; + +import app.application.auth.LoginAuthService; +import app.application.auth.LoginAuthService.LoginTokenBundle; +import app.core.data.response.ApiResponse; +import app.dto.request.AuthCodeExchangeRequest; +import app.dto.response.CsrfTokenResponse; +import app.dto.response.LoginTokenResponse; +import app.dto.response.LoginUserResponse; +import app.openapi.AuthOpenApi; +import app.security.SecurityContextUtil; +import app.security.login.LoginFlowException; +import app.security.login.LoginRefreshCookieWriter; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.util.Arrays; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@AuthOpenApi.ApiTag +@RequestMapping(value = "/api/v1/auth", produces = MediaType.APPLICATION_JSON_VALUE) +public class AuthController { + private final LoginAuthService loginAuthService; + private final LoginRefreshCookieWriter refreshCookieWriter; + + @AuthOpenApi.Exchange + @PostMapping(value = "/exchange", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> exchange( + @Valid @RequestBody AuthCodeExchangeRequest request, HttpServletResponse response) { + LoginTokenBundle bundle = loginAuthService.exchange(request.code()); + refreshCookieWriter.write(response, bundle.refreshToken(), bundle.refreshMaxAge()); + return noStore(tokenResponse(bundle)); + } + + @AuthOpenApi.Csrf + @GetMapping("/csrf") + public ResponseEntity> csrf(HttpServletRequest request) { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken == null) { + csrfToken = (CsrfToken) request.getAttribute("_csrf"); + } + if (csrfToken == null) { + throw new IllegalStateException("CSRF token is unavailable"); + } + return noStore(new CsrfTokenResponse(csrfToken.getHeaderName(), csrfToken.getToken())); + } + + @AuthOpenApi.Refresh + @PostMapping("/refresh") + public ResponseEntity> refresh( + HttpServletRequest request, HttpServletResponse response) { + String refreshToken = refreshCookie(request); + try { + LoginTokenBundle bundle = loginAuthService.refresh(refreshToken); + refreshCookieWriter.write(response, bundle.refreshToken(), bundle.refreshMaxAge()); + return noStore(tokenResponse(bundle)); + } catch (LoginFlowException exception) { + if (exception.isExpireRefreshCookie()) { + refreshCookieWriter.expire(response); + } + throw exception; + } + } + + @AuthOpenApi.Me + @GetMapping("/me") + public ResponseEntity> me() { + UUID userId = SecurityContextUtil.getCurrentLoginUserId(); + return noStore(userResponse(loginAuthService.currentUser(userId))); + } + + private String refreshCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return ""; + } + return Arrays.stream(cookies) + .filter(cookie -> refreshCookieWriter.cookieName().equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(""); + } + + private LoginTokenResponse tokenResponse(LoginTokenBundle bundle) { + return new LoginTokenResponse( + bundle.accessToken(), + "Bearer", + bundle.accessExpiresIn(), + userResponse(bundle.userAccount())); + } + + private LoginUserResponse userResponse(app.domain.user.UserAccount userAccount) { + return new LoginUserResponse( + userAccount.getId(), + userAccount.getDisplayName(), + userAccount.getPrimaryEmail(), + userAccount.getAvatarUrl()); + } + + private ResponseEntity> noStore(T data) { + ResponseEntity> response = ApiResponse.ok(data); + return ResponseEntity.status(response.getStatusCode()) + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .body(response.getBody()); + } +} diff --git a/profanity-api/src/main/java/app/presentation/WordManagementController.java b/profanity-api/src/main/java/app/presentation/WordManagementController.java index f22fcf8..4e421cd 100644 --- a/profanity-api/src/main/java/app/presentation/WordManagementController.java +++ b/profanity-api/src/main/java/app/presentation/WordManagementController.java @@ -39,7 +39,7 @@ public ResponseEntity> requestNewWord( String word = request.word(); String reason = request.reason(); String severity = request.severity().name(); - UUID currentUserId = SecurityContextUtil.getCurrentUserId(); + UUID currentUserId = SecurityContextUtil.getCurrentApiClientId(); var response = switch (request.type()) { case ADD -> wordManagement.requestNewWord(currentUserId, word, reason, severity); diff --git a/profanity-api/src/main/java/app/security/SecurityConfig.java b/profanity-api/src/main/java/app/security/SecurityConfig.java index 12d3152..744261f 100644 --- a/profanity-api/src/main/java/app/security/SecurityConfig.java +++ b/profanity-api/src/main/java/app/security/SecurityConfig.java @@ -1,12 +1,20 @@ package app.security; import app.security.authentication.AuthenticationService; +import app.security.filter.CredentialConflictFilter; +import app.security.filter.CustomAccessDeniedHandler; import app.security.filter.CustomAuthenticationEntryPoint; import app.security.filter.CustomAuthenticationFilter; +import app.security.filter.RequestCredentialResolver; +import app.security.login.LoginSessionProperties; import app.security.oauth2.CookieOAuth2AuthorizationRequestRepository; +import app.security.oauth2.GitHubOAuth2UserService; +import app.security.oauth2.GoogleOidcUserService; import app.security.oauth2.OAuth2LoginFailureHandler; import app.security.oauth2.OAuth2LoginSuccessHandler; import app.security.oauth2.OAuth2SecuritySupportConfig; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -14,6 +22,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -24,32 +33,56 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @Import(OAuth2SecuritySupportConfig.class) @RequiredArgsConstructor public class SecurityConfig { + private static final String AUTH_API_KEY = "AUTH_API_KEY"; + private static final String AUTH_LOGIN_JWT = "AUTH_LOGIN_JWT"; private final AuthenticationService authenticationService; + private final RequestCredentialResolver requestCredentialResolver; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository; + private final GitHubOAuth2UserService gitHubOAuth2UserService; + private final GoogleOidcUserService googleOidcUserService; private final OAuth2LoginSuccessHandler oauth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oauth2LoginFailureHandler; + private final LoginSessionProperties loginSessionProperties; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookiePath("/"); + csrfTokenRepository.setCookieCustomizer( + cookie -> + cookie + .secure(loginSessionProperties.refreshCookie().secure()) + .sameSite(loginSessionProperties.refreshCookie().sameSite())); + + RequestMatcher refreshCsrfMatcher = + request -> + HttpMethod.POST.matches(request.getMethod()) + && "/api/v1/auth/refresh".equals(pathWithinApplication(request)); + return http.cors(cors -> cors.configurationSource(corsConfigurationSource())) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement( sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .csrf(AbstractHttpConfigurer::disable) + .csrf( + csrf -> + csrf.csrfTokenRepository(csrfTokenRepository) + .requireCsrfProtectionMatcher(refreshCsrfMatcher)) .headers( headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) @@ -60,61 +93,106 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { authorization -> authorization.authorizationRequestRepository( cookieOAuth2AuthorizationRequestRepository)) + .userInfoEndpoint( + userInfo -> + userInfo + .userService(gitHubOAuth2UserService) + .oidcUserService(googleOidcUserService)) .successHandler(oauth2LoginSuccessHandler) .failureHandler(oauth2LoginFailureHandler)) .authorizeHttpRequests( - authorizationManagerRequestMatcherRegistry -> - authorizationManagerRequestMatcherRegistry + authorization -> + authorization + .dispatcherTypeMatchers(DispatcherType.ERROR) + .permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()) .permitAll() - .requestMatchers(HttpMethod.GET, "/", "/index.html") + .requestMatchers(HttpMethod.OPTIONS, "/**") .permitAll() - .requestMatchers("/sso/**", "/oauth2/**", "/login/oauth2/code/**") + .requestMatchers(HttpMethod.GET, "/", "/index.html") .permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/clients/**") + .requestMatchers(HttpMethod.GET, "/sso/**") .permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/health") + .requestMatchers( + HttpMethod.GET, "/oauth2/authorization/**", "/login/oauth2/code/**") .permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/ping") + .requestMatchers(HttpMethod.GET, "/api/v1/health", "/api/v1/ping") .permitAll() - .requestMatchers(HttpMethod.GET, "/openapi.json") + .requestMatchers( + HttpMethod.GET, "/openapi.json", "/overview.md", "/llms.txt", "/llm.txt") .permitAll() - .requestMatchers(HttpMethod.GET, "/overview.md") + .requestMatchers(HttpMethod.POST, "/api/v1/clients/register") .permitAll() - .requestMatchers(HttpMethod.GET, "/llms.txt", "/llm.txt") + .requestMatchers( + HttpMethod.GET, "/api/v1/clients/send-email", "/api/v1/auth/csrf") .permitAll() - .requestMatchers("/api/v1/clients/send-email") + .requestMatchers(HttpMethod.PUT, "/api/v1/clients/send-email") .permitAll() - .requestMatchers("/api/v1/auth/**") + .requestMatchers( + HttpMethod.POST, "/api/v1/auth/exchange", "/api/v1/auth/refresh") .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/me", "/api/v1/dashboard/**") + .hasAuthority(AUTH_LOGIN_JWT) + .requestMatchers( + "/api/v1/filter/**", + "/api/v1/clients", + "/api/v1/clients/update", + "/api/v1/clients/reissue", + "/api/v1/word/**", + "/api/v1/sync") + .hasAuthority(AUTH_API_KEY) .anyRequest() - .authenticated()) + .denyAll()) .addFilterBefore( new CustomAuthenticationFilter(authenticationService, customAuthenticationEntryPoint), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore( + new CredentialConflictFilter(requestCredentialResolver, customAuthenticationEntryPoint), + CustomAuthenticationFilter.class) .exceptionHandling( exceptionHandling -> - exceptionHandling.authenticationEntryPoint(customAuthenticationEntryPoint)) + exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler)) .build(); } - /** - * Cors 구성 소스 빈 등록 - * - * @return the cors configuration source - */ @Bean CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowCredentials(false); // 변경 - configuration.setAllowedOrigins(List.of("*")); - configuration.setAllowedMethods( + CorsConfiguration loginConfiguration = new CorsConfiguration(); + loginConfiguration.setAllowCredentials(true); + loginConfiguration.setAllowedOrigins(loginSessionProperties.allowedOrigins()); + loginConfiguration.setAllowedMethods( Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); + loginConfiguration.setAllowedHeaders( + List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE, "X-API-KEY", "X-XSRF-TOKEN")); + + CorsConfiguration legacyApiConfiguration = new CorsConfiguration(); + legacyApiConfiguration.setAllowCredentials(false); + legacyApiConfiguration.setAllowedOrigins(List.of("*")); + legacyApiConfiguration.setAllowedMethods( + Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + legacyApiConfiguration.setAllowedHeaders(List.of("*")); + + return request -> + isLoginBrowserPath(pathWithinApplication(request)) + ? loginConfiguration + : legacyApiConfiguration; + } + + private static boolean isLoginBrowserPath(String path) { + return path.startsWith("/api/v1/auth/") + || path.equals("/api/v1/dashboard") + || path.startsWith("/api/v1/dashboard/"); + } - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; + private static String pathWithinApplication(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; } @Bean diff --git a/profanity-api/src/main/java/app/security/SecurityContextUtil.java b/profanity-api/src/main/java/app/security/SecurityContextUtil.java index 784fab9..083f033 100644 --- a/profanity-api/src/main/java/app/security/SecurityContextUtil.java +++ b/profanity-api/src/main/java/app/security/SecurityContextUtil.java @@ -1,7 +1,10 @@ package app.security; import app.domain.client.PermissionsType; -import app.security.authentication.CustomPrincipal; +import app.security.authentication.ApiKeyPrincipal; +import app.security.authentication.AuthenticationType; +import app.security.authentication.LoginUserPrincipal; +import app.security.authentication.ServicePrincipal; import java.util.List; import java.util.UUID; import lombok.AccessLevel; @@ -18,22 +21,36 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class SecurityContextUtil { - /** - * SecurityContext에서 CustomPrincipal을 조회합니다. authentication이 null인 경우 anonymous 권한을 가진 Principal을 - * 반환합니다. - */ - public static CustomPrincipal getAuthentication() { + /** SecurityContext에서 서비스 인증 주체를 조회합니다. */ + public static ServicePrincipal getAuthentication() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - return CustomPrincipal.anonymous(); + if (authentication == null + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof ServicePrincipal principal)) { + return null; } - return CustomPrincipal.of(authentication.getPrincipal()); + return principal; + } + + /** 현재 인증 타입을 반환합니다. */ + public static AuthenticationType getAuthenticationType() { + ServicePrincipal principal = getAuthentication(); + return principal == null ? null : principal.authenticationType(); + } + + /** 현재 요청이 API Key로 인증됐는지 확인합니다. */ + public static boolean isApiKeyAuthentication() { + return getAuthenticationType() == AuthenticationType.API_KEY; + } + + /** 현재 요청이 로그인 JWT로 인증됐는지 확인합니다. */ + public static boolean isLoginJwtAuthentication() { + return getAuthenticationType() == AuthenticationType.LOGIN_JWT; } /** 현재 접근 주체가 인증된 클라이언트인지 확인합니다. (익명 사용자, 차단 또는 폐기된 상태가 아님) */ public static boolean isVerifiedClient() { - CustomPrincipal principal = getAuthentication(); - if (principal == null || "anonymous".equals(principal.issuerInfo())) { + if (!isApiKeyAuthentication()) { return false; } return !hasPermission(PermissionsType.BLOCK) && !hasPermission(PermissionsType.DISCARD); @@ -51,27 +68,38 @@ public static boolean isDiscardedClient() { /** 현재 접근 주체가 특정 권한을 가지고 있는지 확인하는 유틸리티 메소드입니다. */ private static boolean hasPermission(PermissionsType permissionType) { - CustomPrincipal principal = getAuthentication(); - if (principal == null) { + ServicePrincipal principal = getAuthentication(); + if (!(principal instanceof ApiKeyPrincipal apiKeyPrincipal)) { return false; } - List permissions = principal.permissions(); + List permissions = apiKeyPrincipal.permissions(); log.debug("Checking permission {} for current permissions: {}", permissionType, permissions); - return permissions != null && permissions.contains(permissionType.getValue()); + return permissions.contains(permissionType.getValue()); } /** 현재 사용자가 인증되었는지 확인합니다. */ public static boolean isAuthenticated() { - return SecurityContextHolder.getContext().getAuthentication() != null; + return getAuthentication() != null; } /** - * 현재 인증된 사용자의 ID를 반환합니다. + * 현재 API Key 클라이언트의 ID를 반환합니다. * * @throws IllegalStateException 인증되지 않은 경우 */ + public static UUID getCurrentApiClientId() { + return getApiKeyPrincipalWithCheck().id(); + } + + /** 기존 호출부 호환용 별칭입니다. 새 코드에서는 getCurrentApiClientId를 사용합니다. */ + @Deprecated(forRemoval = false) public static UUID getCurrentUserId() { - return getAuthenticationWithCheck().id(); + return getCurrentApiClientId(); + } + + /** 현재 로그인 사용자의 ID를 반환합니다. */ + public static UUID getCurrentLoginUserId() { + return getLoginUserPrincipalWithCheck().id(); } /** @@ -80,7 +108,17 @@ public static UUID getCurrentUserId() { * @throws IllegalStateException 인증되지 않은 경우 */ public static String getCurrentApikey() { - return getAuthenticationWithCheck().apiKey(); + return getCurrentApiKey(); + } + + /** 현재 API Key 원문을 반환합니다. */ + public static String getCurrentApiKey() { + getApiKeyPrincipalWithCheck(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication.getCredentials() instanceof String apiKey) || apiKey.isBlank()) { + throw new IllegalStateException("API key credential is unavailable"); + } + return apiKey; } /** @@ -98,19 +136,30 @@ public static String getCurrentUserEmail() { * @throws IllegalStateException 인증되지 않은 경우 */ public static List getCurrentUserPermissions() { - return getAuthenticationWithCheck().permissions(); + return getApiKeyPrincipalWithCheck().permissions(); } - /** - * 인증 상태를 체크하고 CustomPrincipal을 반환합니다. - * - * @throws IllegalStateException 인증되지 않은 경우 - */ - private static CustomPrincipal getAuthenticationWithCheck() { - CustomPrincipal principal = getAuthentication(); - if (principal == null || "anonymous".equals(principal.issuerInfo())) { + private static ServicePrincipal getAuthenticationWithCheck() { + ServicePrincipal principal = getAuthentication(); + if (principal == null) { throw new IllegalStateException("Not authenticated"); } return principal; } + + private static ApiKeyPrincipal getApiKeyPrincipalWithCheck() { + ServicePrincipal principal = getAuthenticationWithCheck(); + if (!(principal instanceof ApiKeyPrincipal apiKeyPrincipal)) { + throw new IllegalStateException("API key authentication is required"); + } + return apiKeyPrincipal; + } + + private static LoginUserPrincipal getLoginUserPrincipalWithCheck() { + ServicePrincipal principal = getAuthenticationWithCheck(); + if (!(principal instanceof LoginUserPrincipal loginUserPrincipal)) { + throw new IllegalStateException("Login JWT authentication is required"); + } + return loginUserPrincipal; + } } diff --git a/profanity-api/src/main/java/app/security/aspect/ClientVerificationAspect.java b/profanity-api/src/main/java/app/security/aspect/ClientVerificationAspect.java index 8ea887f..82af8c1 100644 --- a/profanity-api/src/main/java/app/security/aspect/ClientVerificationAspect.java +++ b/profanity-api/src/main/java/app/security/aspect/ClientVerificationAspect.java @@ -29,12 +29,16 @@ public Object verifyClient(ProceedingJoinPoint joinPoint) throws Throwable { Method method = signature.getMethod(); VerifiedClientOnly annotation = method.getAnnotation(VerifiedClientOnly.class); + if (!SecurityContextUtil.isApiKeyAuthentication()) { + return ApiResponse.error(Status.of(FORBIDDEN, "API Key 클라이언트 인증이 필요합니다.")); + } + if (annotation.checkBlocked() && SecurityContextUtil.isBlockedClient()) { - log.info("차단된 클라이언트의 접근이 감지되었습니다. : {}", SecurityContextUtil.getAuthentication()); + log.info("차단된 클라이언트의 접근이 감지되었습니다. clientId={}", SecurityContextUtil.getCurrentApiClientId()); return ApiResponse.error(Status.of(FORBIDDEN, "차단된 클라이언트입니다.")); } if (annotation.checkDiscarded() && SecurityContextUtil.isDiscardedClient()) { - log.info("폐기된 클라이언트의 접근이 감지되었습니다. : {}", SecurityContextUtil.getAuthentication()); + log.info("폐기된 클라이언트의 접근이 감지되었습니다. clientId={}", SecurityContextUtil.getCurrentApiClientId()); return ApiResponse.error(Status.of(FORBIDDEN, "폐기된 클라이언트입니다.")); } diff --git a/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java b/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java new file mode 100644 index 0000000..6fbc8eb --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/ApiKeyAuthenticator.java @@ -0,0 +1,55 @@ +package app.security.authentication; + +import app.application.client.MetadataReader; +import app.domain.client.ClientMetadata; +import app.security.filter.RequestCredential; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApiKeyAuthenticator implements RequestAuthenticator { + private static final String ROLE_PREFIX = "ROLE_"; + + private final MetadataReader clientMetadataReader; + + @Override + public AuthenticationType supports() { + return AuthenticationType.API_KEY; + } + + @Override + public Authentication authenticate(RequestCredential credential) { + String apiKey = credential.value(); + ClientMetadata metadata; + try { + metadata = clientMetadataReader.read(apiKey); + } catch (IllegalArgumentException | NoSuchElementException exception) { + throw new BadCredentialsException(exception.getMessage(), exception); + } + + ApiKeyPrincipal principal = + new ApiKeyPrincipal( + metadata.id(), + metadata.email(), + metadata.issuerInfo(), + metadata.permissions(), + metadata.issuedAt()); + List authorities = new ArrayList<>(); + authorities.add(AuthenticationType.API_KEY.authority()); + authorities.addAll( + principal.permissions().stream().map(permission -> ROLE_PREFIX + permission).toList()); + + return new CustomAuthentication( + AuthenticationType.API_KEY, + apiKey, + AuthorityUtils.createAuthorityList(authorities), + principal); + } +} diff --git a/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java b/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java new file mode 100644 index 0000000..319116c --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/ApiKeyPrincipal.java @@ -0,0 +1,19 @@ +package app.security.authentication; + +import java.util.List; +import java.util.UUID; + +/** 기존 외부 API 클라이언트의 인증 주체입니다. API Key 원문은 포함하지 않습니다. */ +public record ApiKeyPrincipal( + UUID id, String email, String issuerInfo, List permissions, String issuedAt) + implements ServicePrincipal { + + public ApiKeyPrincipal { + permissions = List.copyOf(permissions); + } + + @Override + public AuthenticationType authenticationType() { + return AuthenticationType.API_KEY; + } +} diff --git a/profanity-api/src/main/java/app/security/authentication/AuthenticationService.java b/profanity-api/src/main/java/app/security/authentication/AuthenticationService.java index 6f945d3..89e9f15 100644 --- a/profanity-api/src/main/java/app/security/authentication/AuthenticationService.java +++ b/profanity-api/src/main/java/app/security/authentication/AuthenticationService.java @@ -1,47 +1,47 @@ package app.security.authentication; -import app.application.client.MetadataReader; import app.core.data.response.constant.StatusCode; -import app.domain.client.ClientMetadata; +import app.security.filter.RequestCredential; +import app.security.filter.RequestCredentialResolver; import jakarta.servlet.http.HttpServletRequest; +import java.util.EnumMap; import java.util.List; -import lombok.RequiredArgsConstructor; +import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor public class AuthenticationService { - private static final String AUTH_TOKEN_HEADER_NAME = "X-API-KEY"; - private static final String ROLE_PREFIX = "ROLE_"; - private final MetadataReader clientMetadataReader; + private final RequestCredentialResolver credentialResolver; + private final Map authenticators; + + public AuthenticationService( + RequestCredentialResolver credentialResolver, List authenticators) { + this.credentialResolver = credentialResolver; + EnumMap indexed = + new EnumMap<>(AuthenticationType.class); + for (RequestAuthenticator authenticator : authenticators) { + RequestAuthenticator duplicate = indexed.put(authenticator.supports(), authenticator); + if (duplicate != null) { + throw new IllegalStateException( + "Multiple authenticators registered for " + authenticator.supports()); + } + } + this.authenticators = Map.copyOf(indexed); + } public Authentication getAuthentication(HttpServletRequest request) { - String apiKey = request.getHeader(AUTH_TOKEN_HEADER_NAME); - - if (apiKey == null || apiKey.isEmpty()) { - StatusCode unauthorized = StatusCode.UNAUTHORIZED; - throw new BadCredentialsException(String.valueOf(unauthorized.code())); + RequestCredential credential = credentialResolver.resolve(request); + if (credential.type() == AuthenticationType.OAUTH2_ACCESS_TOKEN) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, StatusCode.OAUTH2_ACCESS_TOKEN_UNSUPPORTED); } - - ClientMetadata metadata = clientMetadataReader.read(apiKey); - - CustomPrincipal principal = - CustomPrincipal.builder() - .apiKey(apiKey) - .id(metadata.id()) - .email(metadata.email()) - .issuerInfo(metadata.issuerInfo()) - .permissions(metadata.permissions()) - .issuedAt(metadata.issuedAt()) - .build(); - - final List authorityList = - principal.permissions().stream().map(permission -> ROLE_PREFIX + permission).toList(); - - return new CustomAuthentication( - apiKey, AuthorityUtils.createAuthorityList(authorityList), principal); + RequestAuthenticator authenticator = authenticators.get(credential.type()); + if (authenticator == null) { + throw new BadCredentialsException(StatusCode.UNAUTHORIZED.stringCode()); + } + return authenticator.authenticate(credential); } } diff --git a/profanity-api/src/main/java/app/security/authentication/AuthenticationType.java b/profanity-api/src/main/java/app/security/authentication/AuthenticationType.java new file mode 100644 index 0000000..c09d685 --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/AuthenticationType.java @@ -0,0 +1,12 @@ +package app.security.authentication; + +/** 요청을 인증한 자격 증명의 종류입니다. */ +public enum AuthenticationType { + API_KEY, + LOGIN_JWT, + OAUTH2_ACCESS_TOKEN; + + public String authority() { + return "AUTH_" + name(); + } +} diff --git a/profanity-api/src/main/java/app/security/authentication/CredentialAuthenticationException.java b/profanity-api/src/main/java/app/security/authentication/CredentialAuthenticationException.java new file mode 100644 index 0000000..255e503 --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/CredentialAuthenticationException.java @@ -0,0 +1,25 @@ +package app.security.authentication; + +import app.core.data.response.constant.StatusCode; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; + +/** 인증 실패의 API 응답 코드와 HTTP 상태를 함께 전달합니다. */ +public class CredentialAuthenticationException extends AuthenticationException { + private final HttpStatus httpStatus; + + public CredentialAuthenticationException(HttpStatus httpStatus, StatusCode statusCode) { + super(statusCode.stringCode()); + this.httpStatus = httpStatus; + } + + public CredentialAuthenticationException( + HttpStatus httpStatus, StatusCode statusCode, Throwable cause) { + super(statusCode.stringCode(), cause); + this.httpStatus = httpStatus; + } + + public HttpStatus httpStatus() { + return httpStatus; + } +} diff --git a/profanity-api/src/main/java/app/security/authentication/CustomAuthentication.java b/profanity-api/src/main/java/app/security/authentication/CustomAuthentication.java index 29aeea5..c662344 100644 --- a/profanity-api/src/main/java/app/security/authentication/CustomAuthentication.java +++ b/profanity-api/src/main/java/app/security/authentication/CustomAuthentication.java @@ -1,33 +1,59 @@ package app.security.authentication; import java.util.Collection; -import lombok.EqualsAndHashCode; -import lombok.extern.slf4j.Slf4j; +import java.util.Objects; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; -@Slf4j -@EqualsAndHashCode(callSuper = false) public class CustomAuthentication extends AbstractAuthenticationToken { - private final String apiKey; - private final CustomPrincipal details; + private final AuthenticationType authenticationType; + private final ServicePrincipal principal; + private Object credentials; public CustomAuthentication( - String apiKey, Collection authorities, CustomPrincipal details) { + AuthenticationType authenticationType, + Object credentials, + Collection authorities, + ServicePrincipal principal) { super(authorities); - this.apiKey = apiKey; - this.details = details; + this.authenticationType = Objects.requireNonNull(authenticationType); + this.credentials = credentials; + this.principal = Objects.requireNonNull(principal); + if (authenticationType != principal.authenticationType()) { + throw new IllegalArgumentException("Authentication type and principal type must match"); + } setAuthenticated(true); } + public AuthenticationType authenticationType() { + return authenticationType; + } + @Override public Object getCredentials() { - return apiKey; + return credentials; + } + + @Override + public ServicePrincipal getPrincipal() { + return principal; + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; } @Override - public Object getPrincipal() { - return details; + public String toString() { + return "CustomAuthentication[type=" + + authenticationType + + ", principalId=" + + principal.id() + + ", authenticated=" + + isAuthenticated() + + "]"; } } diff --git a/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java b/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java index 8b66099..be7bedf 100644 --- a/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java +++ b/profanity-api/src/main/java/app/security/authentication/CustomPrincipal.java @@ -14,6 +14,12 @@ public record CustomPrincipal( List permissions, String issuedAt) implements Serializable { + + @Override + public String toString() { + return "CustomPrincipal[id=" + id + ", email=" + email + ", apiKey=redacted]"; + } + public static CustomPrincipal of(Object principal) { if (principal == null) { return null; diff --git a/profanity-api/src/main/java/app/security/authentication/LoginUserPrincipal.java b/profanity-api/src/main/java/app/security/authentication/LoginUserPrincipal.java new file mode 100644 index 0000000..7aa1934 --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/LoginUserPrincipal.java @@ -0,0 +1,12 @@ +package app.security.authentication; + +import java.util.UUID; + +/** SSO 로그인 후 발급한 JWT로 인증된 사람 사용자의 주체입니다. */ +public record LoginUserPrincipal(UUID id, String email) implements ServicePrincipal { + + @Override + public AuthenticationType authenticationType() { + return AuthenticationType.LOGIN_JWT; + } +} diff --git a/profanity-api/src/main/java/app/security/authentication/RequestAuthenticator.java b/profanity-api/src/main/java/app/security/authentication/RequestAuthenticator.java new file mode 100644 index 0000000..785b8ec --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/RequestAuthenticator.java @@ -0,0 +1,12 @@ +package app.security.authentication; + +import app.security.filter.RequestCredential; +import org.springframework.security.core.Authentication; + +/** 한 종류의 자격 증명만 검증하는 인증기입니다. */ +public interface RequestAuthenticator { + + AuthenticationType supports(); + + Authentication authenticate(RequestCredential credential); +} diff --git a/profanity-api/src/main/java/app/security/authentication/ServicePrincipal.java b/profanity-api/src/main/java/app/security/authentication/ServicePrincipal.java new file mode 100644 index 0000000..8689db6 --- /dev/null +++ b/profanity-api/src/main/java/app/security/authentication/ServicePrincipal.java @@ -0,0 +1,21 @@ +package app.security.authentication; + +import java.io.Serializable; +import java.security.Principal; +import java.util.UUID; + +/** 서비스 내부에서 사용하는 타입 안전한 인증 주체입니다. */ +public sealed interface ServicePrincipal extends Principal, Serializable + permits ApiKeyPrincipal, LoginUserPrincipal { + + UUID id(); + + String email(); + + AuthenticationType authenticationType(); + + @Override + default String getName() { + return id().toString(); + } +} diff --git a/profanity-api/src/main/java/app/security/filter/AuthenticationRoutePolicy.java b/profanity-api/src/main/java/app/security/filter/AuthenticationRoutePolicy.java new file mode 100644 index 0000000..8ce53be --- /dev/null +++ b/profanity-api/src/main/java/app/security/filter/AuthenticationRoutePolicy.java @@ -0,0 +1,52 @@ +package app.security.filter; + +import app.security.authentication.AuthenticationType; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +/** 경로가 Bearer token을 어떤 인증 타입으로 해석할지 결정합니다. */ +@Component +public class AuthenticationRoutePolicy { + private static final String AUTH_ME_PATH = "/api/v1/auth/me"; + private static final String DASHBOARD_ROOT = "/api/v1/dashboard"; + + public Route route(HttpServletRequest request) { + String path = pathWithinApplication(request); + if (AUTH_ME_PATH.equals(path) + || DASHBOARD_ROOT.equals(path) + || path.startsWith(DASHBOARD_ROOT + "/")) { + return Route.LOGIN_USER; + } + return Route.EXTERNAL_API; + } + + private String pathWithinApplication(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; + } + + public enum Route { + LOGIN_USER(AuthenticationType.LOGIN_JWT, false), + EXTERNAL_API(AuthenticationType.OAUTH2_ACCESS_TOKEN, true); + + private final AuthenticationType bearerType; + private final boolean apiKeyAllowed; + + Route(AuthenticationType bearerType, boolean apiKeyAllowed) { + this.bearerType = bearerType; + this.apiKeyAllowed = apiKeyAllowed; + } + + public AuthenticationType bearerType() { + return bearerType; + } + + public boolean apiKeyAllowed() { + return apiKeyAllowed; + } + } +} diff --git a/profanity-api/src/main/java/app/security/filter/CredentialConflictFilter.java b/profanity-api/src/main/java/app/security/filter/CredentialConflictFilter.java new file mode 100644 index 0000000..7bf9468 --- /dev/null +++ b/profanity-api/src/main/java/app/security/filter/CredentialConflictFilter.java @@ -0,0 +1,44 @@ +package app.security.filter; + +import app.security.authentication.CredentialAuthenticationException; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.lang.NonNull; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** 인증 제외 경로를 포함한 모든 요청에서 다중 credential 충돌만 선행 검사합니다. */ +@RequiredArgsConstructor +public class CredentialConflictFilter extends OncePerRequestFilter { + private final RequestCredentialResolver credentialResolver; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + try { + credentialResolver.rejectConflicts(request); + } catch (CredentialAuthenticationException exception) { + SecurityContextHolder.clearContext(); + request.setAttribute("exception", exception); + authenticationEntryPoint.commence(request, response, exception); + return; + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + return request.getDispatcherType() == DispatcherType.ERROR + || HttpMethod.OPTIONS.matches(request.getMethod()); + } +} diff --git a/profanity-api/src/main/java/app/security/filter/CustomAccessDeniedHandler.java b/profanity-api/src/main/java/app/security/filter/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..38663bd --- /dev/null +++ b/profanity-api/src/main/java/app/security/filter/CustomAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package app.security.filter; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final HandlerExceptionResolver resolver; + + public CustomAccessDeniedHandler( + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { + this.resolver = resolver; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException, ServletException { + resolver.resolveException(request, response, null, accessDeniedException); + } +} diff --git a/profanity-api/src/main/java/app/security/filter/CustomAuthenticationEntryPoint.java b/profanity-api/src/main/java/app/security/filter/CustomAuthenticationEntryPoint.java index 22e5287..d08a0de 100644 --- a/profanity-api/src/main/java/app/security/filter/CustomAuthenticationEntryPoint.java +++ b/profanity-api/src/main/java/app/security/filter/CustomAuthenticationEntryPoint.java @@ -2,6 +2,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import app.security.authentication.CredentialAuthenticationException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Objects; @@ -32,5 +33,8 @@ public void commence( Exception exception = (Exception) request.getAttribute("exception"); if (Objects.isNull(exception)) exception = authException; resolver.resolveException(request, response, null, exception); + if (authException instanceof CredentialAuthenticationException credentialException) { + response.setStatus(credentialException.httpStatus().value()); + } } } diff --git a/profanity-api/src/main/java/app/security/filter/CustomAuthenticationFilter.java b/profanity-api/src/main/java/app/security/filter/CustomAuthenticationFilter.java index 3d703cb..1b546cc 100644 --- a/profanity-api/src/main/java/app/security/filter/CustomAuthenticationFilter.java +++ b/profanity-api/src/main/java/app/security/filter/CustomAuthenticationFilter.java @@ -1,12 +1,15 @@ package app.security.filter; import app.security.authentication.AuthenticationService; +import jakarta.servlet.DispatcherType; import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.http.HttpMethod; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -24,25 +27,33 @@ public class CustomAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain) { + @NonNull FilterChain filterChain) + throws ServletException, IOException { + Authentication authentication; try { - Authentication authentication = authenticationService.getAuthentication(request); - SecurityContextHolder.getContext().setAuthentication(authentication); - filterChain.doFilter(request, response); - - } catch (Exception exp) { + authentication = authenticationService.getAuthentication(request); + } catch (AuthenticationException exp) { + SecurityContextHolder.clearContext(); request.setAttribute("exception", exp); - customAuthenticationEntryPoint.commence( - request, response, new AuthenticationException(exp.getMessage()) {}); + customAuthenticationEntryPoint.commence(request, response, exp); + return; } + + var securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + filterChain.doFilter(request, response); } @Override protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { - String path = request.getRequestURI(); + String path = pathWithinApplication(request); String method = request.getMethod(); log.debug("ip : {}, path : {}, method : {}", request.getRemoteAddr(), path, method); - if (PathRequest.toStaticResources().atCommonLocations().matches(request)) { + if (request.getDispatcherType() == DispatcherType.ERROR || HttpMethod.OPTIONS.matches(method)) { + return true; + } + if (isStaticResource(path)) { return true; } @@ -52,4 +63,22 @@ protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { return ExcludePath.getPaths().stream() .anyMatch(excludePath -> excludePath.isMatch(path, method)); } + + private boolean isStaticResource(String path) { + return path.startsWith("/css/") + || path.startsWith("/js/") + || path.startsWith("/images/") + || path.startsWith("/webjars/") + || path.matches("/favicon\\.[^/]+") + || path.matches("/[^/]+/icon-[^/]+"); + } + + private String pathWithinApplication(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; + } } diff --git a/profanity-api/src/main/java/app/security/filter/ExcludePath.java b/profanity-api/src/main/java/app/security/filter/ExcludePath.java index a5e7567..c64764b 100644 --- a/profanity-api/src/main/java/app/security/filter/ExcludePath.java +++ b/profanity-api/src/main/java/app/security/filter/ExcludePath.java @@ -17,20 +17,33 @@ public enum ExcludePath { SSO("sso", "SSO 정적 페이지", List.of(HttpMethod.GET)), OAUTH2("oauth2", "OAuth2 인증 시작", List.of(HttpMethod.GET)), OAUTH2_CALLBACK("login/oauth2/code", "OAuth2 callback", List.of(HttpMethod.GET)), + AUTH_EXCHANGE("/api/v1/auth/exchange", "로그인 코드 교환", List.of(HttpMethod.POST), true), + AUTH_CSRF("/api/v1/auth/csrf", "로그인 CSRF 토큰", List.of(HttpMethod.GET), true), + AUTH_REFRESH("/api/v1/auth/refresh", "로그인 토큰 갱신", List.of(HttpMethod.POST), true), ; private final List paths; private final String description; private final List method; + private final boolean exactPath; ExcludePath(String path, String description, List method) { - this(List.of(path), description, method); + this(List.of(path), description, method, false); } ExcludePath(List paths, String description, List method) { + this(paths, description, method, false); + } + + ExcludePath(String path, String description, List method, boolean exactPath) { + this(List.of(path), description, method, exactPath); + } + + ExcludePath(List paths, String description, List method, boolean exactPath) { this.paths = paths; this.description = description; this.method = method; + this.exactPath = exactPath; } public static List getPaths() { @@ -53,6 +66,9 @@ public boolean isMatch(String path, String method) { } private boolean isPathMatch(String path) { + if (exactPath) { + return this.paths.stream().anyMatch(path::equals); + } return this.paths.stream().anyMatch(excludePath -> path.contains("/" + excludePath)); } } diff --git a/profanity-api/src/main/java/app/security/filter/RequestCredential.java b/profanity-api/src/main/java/app/security/filter/RequestCredential.java new file mode 100644 index 0000000..d2a34a6 --- /dev/null +++ b/profanity-api/src/main/java/app/security/filter/RequestCredential.java @@ -0,0 +1,28 @@ +package app.security.filter; + +import app.security.authentication.AuthenticationType; +import java.util.Objects; + +/** 요청에서 추출한 단일 자격 증명입니다. 문자열 표현에는 원문을 노출하지 않습니다. */ +public final class RequestCredential { + private final AuthenticationType type; + private final String value; + + public RequestCredential(AuthenticationType type, String value) { + this.type = Objects.requireNonNull(type); + this.value = Objects.requireNonNull(value); + } + + public AuthenticationType type() { + return type; + } + + public String value() { + return value; + } + + @Override + public String toString() { + return "RequestCredential[type=" + type + ", value=redacted]"; + } +} diff --git a/profanity-api/src/main/java/app/security/filter/RequestCredentialResolver.java b/profanity-api/src/main/java/app/security/filter/RequestCredentialResolver.java new file mode 100644 index 0000000..b9869d9 --- /dev/null +++ b/profanity-api/src/main/java/app/security/filter/RequestCredentialResolver.java @@ -0,0 +1,100 @@ +package app.security.filter; + +import static app.core.data.response.constant.StatusCode.AMBIGUOUS_CREDENTIALS; +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static app.core.data.response.constant.StatusCode.UNAUTHORIZED; + +import app.security.authentication.AuthenticationType; +import app.security.authentication.CredentialAuthenticationException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RequestCredentialResolver { + public static final String API_KEY_HEADER = "X-API-KEY"; + private static final String BEARER_SCHEME = "Bearer"; + + private final AuthenticationRoutePolicy routePolicy; + + public RequestCredential resolve(HttpServletRequest request) { + rejectConflicts(request); + List apiKeyHeaders = headerValues(request, API_KEY_HEADER); + List authorizationHeaders = headerValues(request, HttpHeaders.AUTHORIZATION); + boolean hasApiKeyHeader = !apiKeyHeaders.isEmpty(); + boolean hasAuthorizationHeader = !authorizationHeaders.isEmpty(); + + AuthenticationRoutePolicy.Route route = routePolicy.route(request); + if (hasApiKeyHeader) { + if (!route.apiKeyAllowed()) { + throw new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } + String apiKey = apiKeyHeaders.get(0); + if (apiKey == null || apiKey.isBlank()) { + throw new BadCredentialsException(UNAUTHORIZED.stringCode()); + } + return new RequestCredential(AuthenticationType.API_KEY, apiKey); + } + + if (hasAuthorizationHeader) { + return bearerCredential(authorizationHeaders.get(0), route.bearerType()); + } + + if (route == AuthenticationRoutePolicy.Route.LOGIN_USER) { + throw new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } + throw new BadCredentialsException(UNAUTHORIZED.stringCode()); + } + + public void rejectConflicts(HttpServletRequest request) { + List apiKeyHeaders = headerValues(request, API_KEY_HEADER); + List authorizationHeaders = headerValues(request, HttpHeaders.AUTHORIZATION); + if (hasDuplicates(apiKeyHeaders) + || hasDuplicates(authorizationHeaders) + || (!apiKeyHeaders.isEmpty() && !authorizationHeaders.isEmpty())) { + throw new CredentialAuthenticationException(HttpStatus.BAD_REQUEST, AMBIGUOUS_CREDENTIALS); + } + } + + private RequestCredential bearerCredential(String header, AuthenticationType bearerType) { + if (header == null) { + throw invalidBearer(bearerType); + } + int separator = header.indexOf(' '); + if (separator <= 0 + || !BEARER_SCHEME.equalsIgnoreCase(header.substring(0, separator)) + || header.substring(separator + 1).isBlank() + || header.substring(separator + 1).indexOf(' ') >= 0) { + throw invalidBearer(bearerType); + } + return new RequestCredential(bearerType, header.substring(separator + 1)); + } + + private CredentialAuthenticationException invalidBearer(AuthenticationType bearerType) { + if (bearerType == AuthenticationType.LOGIN_JWT) { + return new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } + return new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, UNAUTHORIZED); + } + + private List headerValues(HttpServletRequest request, String name) { + Enumeration values = request.getHeaders(name); + List result = new ArrayList<>(); + while (values != null && values.hasMoreElements()) { + result.add(values.nextElement()); + } + return result; + } + + private boolean hasDuplicates(List values) { + return values.size() > 1 + || (values.size() == 1 && values.get(0) != null && values.get(0).contains(",")); + } +} diff --git a/profanity-api/src/main/java/app/security/jwt/IssuedAccessToken.java b/profanity-api/src/main/java/app/security/jwt/IssuedAccessToken.java new file mode 100644 index 0000000..03f61a2 --- /dev/null +++ b/profanity-api/src/main/java/app/security/jwt/IssuedAccessToken.java @@ -0,0 +1,28 @@ +package app.security.jwt; + +import java.time.Instant; +import java.util.Objects; + +/** 발급된 access token입니다. 문자열 표현에는 token 원문을 노출하지 않습니다. */ +public final class IssuedAccessToken { + private final String token; + private final Instant expiresAt; + + public IssuedAccessToken(String token, Instant expiresAt) { + this.token = Objects.requireNonNull(token); + this.expiresAt = Objects.requireNonNull(expiresAt); + } + + public String token() { + return token; + } + + public Instant expiresAt() { + return expiresAt; + } + + @Override + public String toString() { + return "IssuedAccessToken[token=redacted, expiresAt=" + expiresAt + "]"; + } +} diff --git a/profanity-api/src/main/java/app/security/jwt/JwtKeyConfig.java b/profanity-api/src/main/java/app/security/jwt/JwtKeyConfig.java new file mode 100644 index 0000000..54b5939 --- /dev/null +++ b/profanity-api/src/main/java/app/security/jwt/JwtKeyConfig.java @@ -0,0 +1,117 @@ +package app.security.jwt; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.text.ParseException; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Slf4j +@Configuration +@EnableConfigurationProperties({JwtKeyProperties.class, JwtSecurityProperties.class}) +public class JwtKeyConfig { + + @Bean + JwtKeyMaterial jwtKeyMaterial(JwtKeyProperties properties, Environment environment) { + if (hasText(properties.privateJwk())) { + return configuredKeyMaterial(properties); + } + if (environment.acceptsProfiles(Profiles.of("prod"))) { + throw new IllegalStateException("LOGIN_JWT_PRIVATE_JWK must be configured in prod"); + } + log.warn("LOGIN_JWT_PRIVATE_JWK is absent; using an ephemeral RSA key outside prod"); + return ephemeralKeyMaterial(); + } + + @Bean + JwtEncoder loginJwtEncoder(JwtKeyMaterial keyMaterial) { + JWKSource source = new ImmutableJWKSet<>(new JWKSet(keyMaterial.privateKey())); + return new NimbusJwtEncoder(source); + } + + @Bean + JwtDecoder loginJwtDecoder(JwtKeyMaterial keyMaterial) { + JWKSource source = + new ImmutableJWKSet<>(new JWKSet(keyMaterial.verificationKeys())); + JWSKeySelector selector = + new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, source); + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(selector); + return new NimbusJwtDecoder(processor); + } + + private JwtKeyMaterial configuredKeyMaterial(JwtKeyProperties properties) { + try { + RSAKey privateKey = RSAKey.parse(properties.privateJwk()); + if (!privateKey.isPrivate() || !hasText(privateKey.getKeyID())) { + throw new IllegalStateException("LOGIN_JWT_PRIVATE_JWK requires private key data and kid"); + } + + List verificationKeys = + hasText(properties.publicJwkSet()) + ? JWKSet.parse(properties.publicJwkSet()).getKeys().stream() + .map(key -> (JWK) requireRsaPublicKey(key)) + .toList() + : List.of(privateKey.toPublicJWK()); + RSAKey activePublicKey = privateKey.toPublicJWK(); + boolean activeKeyPresent = + verificationKeys.stream() + .map(RSAKey.class::cast) + .anyMatch( + key -> + privateKey.getKeyID().equals(key.getKeyID()) + && activePublicKey.getModulus().equals(key.getModulus()) + && activePublicKey.getPublicExponent().equals(key.getPublicExponent())); + if (!activeKeyPresent) { + throw new IllegalStateException( + "LOGIN_JWT_PUBLIC_JWK_SET does not contain the active private key kid"); + } + return new JwtKeyMaterial(privateKey, verificationKeys); + } catch (ParseException exception) { + throw new IllegalStateException("Failed to parse login JWT JWK configuration", exception); + } + } + + private JwtKeyMaterial ephemeralKeyMaterial() { + try { + RSAKey privateKey = new RSAKeyGenerator(2048).keyID("local-" + UUID.randomUUID()).generate(); + return new JwtKeyMaterial(privateKey, List.of(privateKey.toPublicJWK())); + } catch (Exception exception) { + throw new IllegalStateException("Failed to generate ephemeral login JWT key", exception); + } + } + + private RSAKey requireRsaPublicKey(JWK key) { + JWK publicKey = key.toPublicJWK(); + if (!(publicKey instanceof RSAKey rsaKey) || !hasText(rsaKey.getKeyID())) { + throw new IllegalStateException( + "LOGIN_JWT_PUBLIC_JWK_SET must contain public RSA keys with kid"); + } + return rsaKey; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + record JwtKeyMaterial(RSAKey privateKey, List verificationKeys) {} +} diff --git a/profanity-api/src/main/java/app/security/jwt/JwtKeyProperties.java b/profanity-api/src/main/java/app/security/jwt/JwtKeyProperties.java new file mode 100644 index 0000000..da8cc89 --- /dev/null +++ b/profanity-api/src/main/java/app/security/jwt/JwtKeyProperties.java @@ -0,0 +1,6 @@ +package app.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.security.jwt.keys") +public record JwtKeyProperties(String privateJwk, String publicJwkSet) {} diff --git a/profanity-api/src/main/java/app/security/jwt/JwtSecurityProperties.java b/profanity-api/src/main/java/app/security/jwt/JwtSecurityProperties.java new file mode 100644 index 0000000..df72cba --- /dev/null +++ b/profanity-api/src/main/java/app/security/jwt/JwtSecurityProperties.java @@ -0,0 +1,25 @@ +package app.security.jwt; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "app.security.jwt") +public record JwtSecurityProperties( + @NotBlank String issuer, + @NotBlank String audience, + @NotNull Duration accessTokenTtl, + @NotNull Duration clockSkew) { + + public JwtSecurityProperties { + if (accessTokenTtl != null && (accessTokenTtl.isZero() || accessTokenTtl.isNegative())) { + throw new IllegalArgumentException("accessTokenTtl must be positive"); + } + if (clockSkew != null && clockSkew.isNegative()) { + throw new IllegalArgumentException("clockSkew must not be negative"); + } + } +} diff --git a/profanity-api/src/main/java/app/security/jwt/LoginJwtService.java b/profanity-api/src/main/java/app/security/jwt/LoginJwtService.java new file mode 100644 index 0000000..8c9b6bb --- /dev/null +++ b/profanity-api/src/main/java/app/security/jwt/LoginJwtService.java @@ -0,0 +1,216 @@ +package app.security.jwt; + +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_EXPIRED; +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static app.core.data.response.constant.StatusCode.USER_INACTIVE; + +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import app.security.authentication.AuthenticationType; +import app.security.authentication.CredentialAuthenticationException; +import app.security.authentication.CustomAuthentication; +import app.security.authentication.LoginUserPrincipal; +import app.security.authentication.RequestAuthenticator; +import app.security.filter.RequestCredential; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidationException; +import org.springframework.stereotype.Component; + +@Component +public class LoginJwtService implements RequestAuthenticator { + private static final String TOKEN_USE_CLAIM = "token_use"; + private static final String AUTH_TYPE_CLAIM = "auth_type"; + private static final String ACCESS_TOKEN_USE = "access"; + private static final String USER_AUTHORITY = "ROLE_USER"; + private static final String RS256 = "RS256"; + + private final JwtEncoder jwtEncoder; + private final JwtDecoder jwtDecoder; + private final JwtSecurityProperties properties; + private final UserAccountRepository userAccountRepository; + private final Clock clock; + + @Autowired + public LoginJwtService( + JwtEncoder jwtEncoder, + JwtDecoder jwtDecoder, + JwtSecurityProperties properties, + UserAccountRepository userAccountRepository) { + this(jwtEncoder, jwtDecoder, properties, userAccountRepository, Clock.systemUTC()); + } + + LoginJwtService( + JwtEncoder jwtEncoder, + JwtDecoder jwtDecoder, + JwtSecurityProperties properties, + UserAccountRepository userAccountRepository, + Clock clock) { + this.jwtEncoder = jwtEncoder; + this.jwtDecoder = jwtDecoder; + this.properties = properties; + this.userAccountRepository = userAccountRepository; + this.clock = clock; + } + + public IssuedAccessToken issue(UserAccount userAccount) { + requireActive(userAccount); + Instant issuedAt = clock.instant().truncatedTo(ChronoUnit.SECONDS); + Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); + JwtClaimsSet claims = + JwtClaimsSet.builder() + .issuer(properties.issuer()) + .audience(List.of(properties.audience())) + .subject(userAccount.getId().toString()) + .issuedAt(issuedAt) + .notBefore(issuedAt) + .expiresAt(expiresAt) + .id(UUID.randomUUID().toString()) + .claim(TOKEN_USE_CLAIM, ACCESS_TOKEN_USE) + .claim(AUTH_TYPE_CLAIM, AuthenticationType.LOGIN_JWT.name()) + .build(); + JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).type("JWT").build(); + String token = jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + return new IssuedAccessToken(token, expiresAt); + } + + public Authentication authenticate(String token) { + Jwt jwt = decode(token); + UUID userId; + try { + userId = validate(jwt); + } catch (CredentialAuthenticationException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID, exception); + } + UserAccount userAccount = + userAccountRepository + .findById(userId) + .orElseThrow( + () -> + new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID)); + requireActive(userAccount); + + LoginUserPrincipal principal = + new LoginUserPrincipal(userAccount.getId(), userAccount.getPrimaryEmail()); + return new CustomAuthentication( + AuthenticationType.LOGIN_JWT, + null, + List.of( + new SimpleGrantedAuthority(AuthenticationType.LOGIN_JWT.authority()), + new SimpleGrantedAuthority(USER_AUTHORITY)), + principal); + } + + @Override + public AuthenticationType supports() { + return AuthenticationType.LOGIN_JWT; + } + + @Override + public Authentication authenticate(RequestCredential credential) { + if (credential.type() != AuthenticationType.LOGIN_JWT) { + throw invalidToken(); + } + return authenticate(credential.value()); + } + + private Jwt decode(String token) { + if (token == null || token.isBlank()) { + throw invalidToken(); + } + try { + return jwtDecoder.decode(token); + } catch (JwtValidationException exception) { + if (isExpired(exception.getErrors())) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_EXPIRED, exception); + } + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID, exception); + } catch (JwtException | IllegalArgumentException exception) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID, exception); + } + } + + private UUID validate(Jwt jwt) { + Instant now = clock.instant(); + Instant issuedAt = jwt.getIssuedAt(); + Instant notBefore = jwt.getNotBefore(); + Instant expiresAt = jwt.getExpiresAt(); + + if (!RS256.equals(Objects.toString(jwt.getHeaders().get("alg"), null)) + || !properties.issuer().equals(jwt.getClaimAsString(JwtClaimNames.ISS)) + || jwt.getAudience() == null + || !jwt.getAudience().contains(properties.audience()) + || !ACCESS_TOKEN_USE.equals(jwt.getClaimAsString(TOKEN_USE_CLAIM)) + || !AuthenticationType.LOGIN_JWT.name().equals(jwt.getClaimAsString(AUTH_TYPE_CLAIM)) + || issuedAt == null + || notBefore == null + || expiresAt == null + || jwt.getId() == null + || jwt.getId().isBlank()) { + throw invalidToken(); + } + if (expiresAt.plus(properties.clockSkew()).isBefore(now) + || expiresAt.plus(properties.clockSkew()).equals(now)) { + throw new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_EXPIRED); + } + if (issuedAt.minus(properties.clockSkew()).isAfter(now) + || notBefore.minus(properties.clockSkew()).isAfter(now) + || issuedAt.isAfter(expiresAt) + || notBefore.isAfter(expiresAt)) { + throw invalidToken(); + } + + try { + return UUID.fromString(jwt.getSubject()); + } catch (RuntimeException exception) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID, exception); + } + } + + private void requireActive(UserAccount userAccount) { + if (userAccount == null || !userAccount.isActive()) { + throw new CredentialAuthenticationException(HttpStatus.FORBIDDEN, USER_INACTIVE); + } + } + + private boolean isExpired(Iterable errors) { + for (OAuth2Error error : errors) { + String description = error.getDescription(); + if (description != null && description.toLowerCase(Locale.ROOT).contains("expired")) { + return true; + } + } + return false; + } + + private CredentialAuthenticationException invalidToken() { + return new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } +} diff --git a/profanity-api/src/main/java/app/security/login/LoginAuthSupportConfig.java b/profanity-api/src/main/java/app/security/login/LoginAuthSupportConfig.java new file mode 100644 index 0000000..65c72a0 --- /dev/null +++ b/profanity-api/src/main/java/app/security/login/LoginAuthSupportConfig.java @@ -0,0 +1,55 @@ +package app.security.login; + +import java.net.URI; +import java.time.Clock; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +@Configuration +@EnableConfigurationProperties(LoginSessionProperties.class) +public class LoginAuthSupportConfig { + + @Bean + Clock loginAuthClock() { + return Clock.systemUTC(); + } + + @Bean + LoginSecurityConfigurationValidator loginSecurityConfigurationValidator( + LoginSessionProperties properties, Environment environment) { + return new LoginSecurityConfigurationValidator(properties, environment); + } + + static final class LoginSecurityConfigurationValidator { + LoginSecurityConfigurationValidator( + LoginSessionProperties properties, Environment environment) { + if (properties.allowedOrigins().stream().anyMatch("*"::equals)) { + throw new IllegalStateException("Credentialed login CORS cannot use a wildcard origin"); + } + properties.allowedOrigins().forEach(LoginSecurityConfigurationValidator::validateOrigin); + if (environment.acceptsProfiles(Profiles.of("prod")) + && !properties.refreshCookie().secure()) { + throw new IllegalStateException("LOGIN_REFRESH_COOKIE_SECURE must be true in prod"); + } + } + + private static void validateOrigin(String origin) { + URI uri; + try { + uri = URI.create(origin); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("Invalid SSO frontend origin", exception); + } + if (uri.getScheme() == null + || uri.getHost() == null + || uri.getPath() != null && !uri.getPath().isEmpty() + || uri.getQuery() != null + || uri.getFragment() != null) { + throw new IllegalStateException("SSO frontend origins must contain scheme and host only"); + } + } + } +} diff --git a/profanity-api/src/main/java/app/security/login/LoginFlowException.java b/profanity-api/src/main/java/app/security/login/LoginFlowException.java new file mode 100644 index 0000000..727215f --- /dev/null +++ b/profanity-api/src/main/java/app/security/login/LoginFlowException.java @@ -0,0 +1,25 @@ +package app.security.login; + +import app.core.data.response.constant.StatusCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; + +@Getter +public class LoginFlowException extends AuthenticationException { + private final StatusCode statusCode; + private final HttpStatus httpStatus; + private final boolean expireRefreshCookie; + + public LoginFlowException(StatusCode statusCode, HttpStatus httpStatus) { + this(statusCode, httpStatus, false); + } + + public LoginFlowException( + StatusCode statusCode, HttpStatus httpStatus, boolean expireRefreshCookie) { + super(statusCode.name()); + this.statusCode = statusCode; + this.httpStatus = httpStatus; + this.expireRefreshCookie = expireRefreshCookie; + } +} diff --git a/profanity-api/src/main/java/app/security/login/LoginRefreshCookieWriter.java b/profanity-api/src/main/java/app/security/login/LoginRefreshCookieWriter.java new file mode 100644 index 0000000..2f079d4 --- /dev/null +++ b/profanity-api/src/main/java/app/security/login/LoginRefreshCookieWriter.java @@ -0,0 +1,37 @@ +package app.security.login; + +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoginRefreshCookieWriter { + private final LoginSessionProperties properties; + + public void write(HttpServletResponse response, String refreshToken, Duration maxAge) { + response.addHeader(HttpHeaders.SET_COOKIE, cookie(refreshToken, maxAge).toString()); + } + + public void expire(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, cookie("", Duration.ZERO).toString()); + } + + public String cookieName() { + return properties.refreshCookie().name(); + } + + private ResponseCookie cookie(String value, Duration maxAge) { + LoginSessionProperties.RefreshCookie properties = this.properties.refreshCookie(); + return ResponseCookie.from(properties.name(), value) + .httpOnly(true) + .secure(properties.secure()) + .sameSite(properties.sameSite()) + .path(properties.path()) + .maxAge(maxAge) + .build(); + } +} diff --git a/profanity-api/src/main/java/app/security/login/LoginSessionProperties.java b/profanity-api/src/main/java/app/security/login/LoginSessionProperties.java new file mode 100644 index 0000000..d4e6ce2 --- /dev/null +++ b/profanity-api/src/main/java/app/security/login/LoginSessionProperties.java @@ -0,0 +1,24 @@ +package app.security.login; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "security.login") +public record LoginSessionProperties( + @NotNull Duration exchangeCodeTtl, + @NotNull Duration refreshTokenTtl, + @NotNull Duration absoluteSessionTtl, + @NotNull Duration refreshReuseGrace, + @NotEmpty List allowedOrigins, + @Valid @NotNull RefreshCookie refreshCookie) { + + public record RefreshCookie( + @NotBlank String name, boolean secure, @NotBlank String sameSite, @NotBlank String path) {} +} diff --git a/profanity-api/src/main/java/app/security/login/SecureOpaqueTokenService.java b/profanity-api/src/main/java/app/security/login/SecureOpaqueTokenService.java new file mode 100644 index 0000000..08e0874 --- /dev/null +++ b/profanity-api/src/main/java/app/security/login/SecureOpaqueTokenService.java @@ -0,0 +1,43 @@ +package app.security.login; + +import app.domain.auth.Sha256Hash; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +@Component +public class SecureOpaqueTokenService { + private static final int TOKEN_BYTES = 32; + private final SecureRandom secureRandom = new SecureRandom(); + + public OpaqueToken generate() { + byte[] value = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(value); + String plaintext = Base64.getUrlEncoder().withoutPadding().encodeToString(value); + return new OpaqueToken(plaintext, hash(plaintext)); + } + + public Sha256Hash hash(String plaintext) { + if (plaintext == null || plaintext.isBlank()) { + throw new IllegalArgumentException("Opaque token must not be blank"); + } + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(plaintext.getBytes(StandardCharsets.UTF_8)); + return new Sha256Hash(HexFormat.of().formatHex(digest)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", exception); + } + } + + public record OpaqueToken(String plaintext, Sha256Hash hash) { + @Override + public String toString() { + return "OpaqueToken[redacted]"; + } + } +} diff --git a/profanity-api/src/main/java/app/security/oauth2/GitHubOAuth2UserService.java b/profanity-api/src/main/java/app/security/oauth2/GitHubOAuth2UserService.java new file mode 100644 index 0000000..c8e6d17 --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/GitHubOAuth2UserService.java @@ -0,0 +1,120 @@ +package app.security.oauth2; + +import java.net.URI; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.util.UriComponentsBuilder; + +/** GitHub 기본 프로필에 검증된 대표 이메일을 보강합니다. */ +public class GitHubOAuth2UserService implements OAuth2UserService { + private static final String GITHUB_REGISTRATION_ID = "github"; + private static final String INVALID_USER_INFO_RESPONSE = "invalid_user_info_response"; + private static final MediaType GITHUB_JSON = + MediaType.parseMediaType("application/vnd.github+json"); + + private final OAuth2UserService delegate; + private final RestClient restClient; + + public GitHubOAuth2UserService() { + this(new DefaultOAuth2UserService(), RestClient.create()); + } + + GitHubOAuth2UserService( + OAuth2UserService delegate, RestClient restClient) { + this.delegate = delegate; + this.restClient = restClient; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User user = delegate.loadUser(userRequest); + if (!GITHUB_REGISTRATION_ID.equals(userRequest.getClientRegistration().getRegistrationId())) { + return user; + } + + String verifiedPrimaryEmail = loadVerifiedPrimaryEmail(userRequest); + Map attributes = new LinkedHashMap<>(user.getAttributes()); + attributes.put("email", verifiedPrimaryEmail); + attributes.put("email_verified", true); + String userNameAttributeName = + userRequest + .getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUserNameAttributeName(); + return new DefaultOAuth2User(user.getAuthorities(), attributes, userNameAttributeName); + } + + private String loadVerifiedPrimaryEmail(OAuth2UserRequest userRequest) { + GitHubEmail[] emails; + try { + emails = + restClient + .get() + .uri(emailEndpoint(userRequest)) + .headers( + headers -> headers.setBearerAuth(userRequest.getAccessToken().getTokenValue())) + .accept(GITHUB_JSON) + .retrieve() + .body(GitHubEmail[].class); + } catch (RestClientException | IllegalArgumentException exception) { + throw invalidUserInfo("Failed to obtain GitHub email information", exception); + } + + if (emails == null) { + throw invalidUserInfo("GitHub verified primary email is unavailable"); + } + return Arrays.stream(emails) + .filter(GitHubEmail::primary) + .filter(GitHubEmail::verified) + .map(GitHubEmail::email) + .filter(email -> email != null && !email.isBlank()) + .map(String::trim) + .findFirst() + .orElseThrow(() -> invalidUserInfo("GitHub verified primary email is unavailable")); + } + + private URI emailEndpoint(OAuth2UserRequest userRequest) { + URI userInfoEndpoint = + URI.create( + userRequest + .getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUri()); + String path = userInfoEndpoint.getPath(); + String normalizedPath = + path == null || path.isBlank() + ? "" + : path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return UriComponentsBuilder.fromUri(userInfoEndpoint) + .replacePath(normalizedPath + "/emails") + .replaceQuery(null) + .fragment(null) + .build(true) + .toUri(); + } + + private OAuth2AuthenticationException invalidUserInfo(String description) { + OAuth2Error error = new OAuth2Error(INVALID_USER_INFO_RESPONSE, description, null); + return new OAuth2AuthenticationException(error, error.toString()); + } + + private OAuth2AuthenticationException invalidUserInfo(String description, Throwable cause) { + OAuth2Error error = new OAuth2Error(INVALID_USER_INFO_RESPONSE, description, null); + return new OAuth2AuthenticationException(error, error.toString(), cause); + } + + private record GitHubEmail(String email, boolean primary, boolean verified) {} +} diff --git a/profanity-api/src/main/java/app/security/oauth2/GoogleOidcUserService.java b/profanity-api/src/main/java/app/security/oauth2/GoogleOidcUserService.java new file mode 100644 index 0000000..77fe68c --- /dev/null +++ b/profanity-api/src/main/java/app/security/oauth2/GoogleOidcUserService.java @@ -0,0 +1,55 @@ +package app.security.oauth2; + +import java.util.Locale; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** Google OIDC 사용자의 검증된 이메일을 로그인 필수 조건으로 확인합니다. */ +public class GoogleOidcUserService implements OAuth2UserService { + private static final String GOOGLE_REGISTRATION_ID = "google"; + private static final String INVALID_USER_INFO_RESPONSE = "invalid_user_info_response"; + + private final OAuth2UserService delegate; + + public GoogleOidcUserService() { + this(new OidcUserService()); + } + + GoogleOidcUserService(OAuth2UserService delegate) { + this.delegate = delegate; + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser user = delegate.loadUser(userRequest); + if (GOOGLE_REGISTRATION_ID.equals(userRequest.getClientRegistration().getRegistrationId())) { + requireAuthoritativeVerifiedEmail(user); + } + return user; + } + + private void requireAuthoritativeVerifiedEmail(OidcUser user) { + String email = user.getAttribute("email"); + Boolean emailVerified = user.getAttribute("email_verified"); + String hostedDomain = user.getIdToken().getClaimAsString("hd"); + boolean emailAuthoritative = + email != null + && (email.toLowerCase(Locale.ROOT).endsWith("@gmail.com") + || hostedDomain != null && !hostedDomain.isBlank()); + if (email == null + || email.isBlank() + || !Boolean.TRUE.equals(emailVerified) + || !emailAuthoritative) { + OAuth2Error error = + new OAuth2Error( + INVALID_USER_INFO_RESPONSE, + "Google authoritative verified email is unavailable", + null); + throw new OAuth2AuthenticationException(error, error.toString()); + } + } +} diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java index 33d551d..a81e3fa 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginFailureHandler.java @@ -1,14 +1,13 @@ package app.security.oauth2; -import app.core.data.response.Status; import app.core.data.response.constant.StatusCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -22,27 +21,14 @@ public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { - Status status = Status.of(StatusCode.OAUTH2_LOGIN_FAILED, exception.getMessage()); - - log.warn("OAuth2 login failed. message={}", exception.getMessage()); + log.warn("OAuth2 login failed. exceptionType={}", exception.getClass().getSimpleName()); + response.setHeader(HttpHeaders.CACHE_CONTROL, CacheControl.noStore().getHeaderValue()); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); response.sendRedirect( ssoFrontendProperties.redirectUri() + "#error=oauth2_login_failed" + "&statusCode=" - + status.code() - + "&statusMessage=" - + encode(status.message()) - + "&statusDescription=" - + encode(status.description()) - + "&statusDetailDescription=" - + encode(status.DetailDescription())); - } - - private String encode(String value) { - if (value == null) { - return ""; - } - return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + + StatusCode.OAUTH2_LOGIN_FAILED.code()); } } diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java index 4cfac32..ff42e6a 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2LoginSuccessHandler.java @@ -1,113 +1,144 @@ package app.security.oauth2; +import app.application.auth.SsoLoginCompletionService; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.UUID; +import java.util.Locale; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @Slf4j @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { - - private static final String MOCK_TOKEN_PREFIX = "mock_dashboard_token_"; + private static final String INVALID_USER_INFO_RESPONSE = "invalid_user_info_response"; private final SsoFrontendProperties ssoFrontendProperties; + private final SsoLoginCompletionService loginCompletionService; @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal(); - String provider = registrationId(authentication); - String mockToken = MOCK_TOKEN_PREFIX + UUID.randomUUID().toString().replace("-", ""); - ProviderProfile providerProfile = providerProfile(provider, oauth2User); - - log.info( - "OAuth2 login succeeded. provider={}, providerUserId={}, providerLogin={}, providerEmail={}", - provider, - providerProfile.userId(), - providerProfile.login(), - providerProfile.email()); - - response.sendRedirect( - ssoFrontendProperties.redirectUri() - + "#provider=" - + encode(provider) - + "&providerUserId=" - + encode(providerProfile.userId()) - + "&providerLogin=" - + encode(providerProfile.login()) - + "&providerEmail=" - + encode(providerProfile.email()) - + "&githubUserId=" - + encode(providerProfile.githubUserId()) - + "&githubLogin=" - + encode(providerProfile.githubLogin()) - + "&googleUserId=" - + encode(providerProfile.googleUserId()) - + "&googleEmail=" - + encode(providerProfile.googleEmail()) - + "&dashboardAccessToken=" - + encode(mockToken)); + if (!(authentication instanceof OAuth2AuthenticationToken oauthAuthentication) + || !(authentication.getPrincipal() instanceof OAuth2User oauth2User)) { + throw new IllegalArgumentException("OAuth2 login authentication is required"); + } + + OAuthProvider provider = + OAuthProvider.from(oauthAuthentication.getAuthorizedClientRegistrationId()); + OAuthLoginProfile profile = providerProfile(provider, oauth2User); + String exchangeCode = loginCompletionService.issueExchangeCode(profile); + + log.info("OAuth2 login succeeded. provider={}", provider.value()); + response.setHeader(HttpHeaders.CACHE_CONTROL, CacheControl.noStore().getHeaderValue()); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + response.sendRedirect(ssoFrontendProperties.redirectUri() + "#code=" + encode(exchangeCode)); } - private ProviderProfile providerProfile(String provider, OAuth2User oauth2User) { + private OAuthLoginProfile providerProfile(OAuthProvider provider, OAuth2User oauth2User) { return switch (provider) { - case "github" -> - new ProviderProfile( - attributeAsString(oauth2User, "id"), - attributeAsString(oauth2User, "login"), - attributeAsString(oauth2User, "email"), - attributeAsString(oauth2User, "id"), - attributeAsString(oauth2User, "login"), - "", - ""); - case "google" -> - new ProviderProfile( - attributeAsString(oauth2User, "sub"), - attributeAsString(oauth2User, "name"), - attributeAsString(oauth2User, "email"), - "", - "", - attributeAsString(oauth2User, "sub"), - attributeAsString(oauth2User, "email")); - default -> new ProviderProfile("", "", "", "", "", "", ""); + case GITHUB -> githubProfile(oauth2User); + case GOOGLE -> googleProfile(oauth2User); }; } - private String encode(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + private OAuthLoginProfile githubProfile(OAuth2User user) { + String providerUserId = requiredAttribute(user, "id"); + String username = attribute(user, "login"); + String email = requiredVerifiedEmail(user); + String displayName = firstNonBlank(attribute(user, "name"), username, providerUserId); + return new OAuthLoginProfile( + OAuthProvider.GITHUB, + providerUserId, + email, + true, + true, + username, + displayName, + attribute(user, "avatar_url")); + } + + private OAuthLoginProfile googleProfile(OAuth2User user) { + String providerUserId = requiredAttribute(user, "sub"); + String email = requiredVerifiedEmail(user); + requireGoogleAuthoritativeEmail(user, email); + String displayName = firstNonBlank(attribute(user, "name"), email, providerUserId); + return new OAuthLoginProfile( + OAuthProvider.GOOGLE, + providerUserId, + email, + true, + true, + email, + displayName, + attribute(user, "picture")); + } + + private String requiredAttribute(OAuth2User user, String name) { + String value = attribute(user, name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("OAuth2 provider identity is missing"); + } + return value; } - private String attributeAsString(OAuth2User oauth2User, String attributeName) { - Object attribute = oauth2User.getAttribute(attributeName); - if (attribute == null) { - return ""; + private String requiredVerifiedEmail(OAuth2User user) { + String email = attribute(user, "email"); + Boolean emailVerified = user.getAttribute("email_verified"); + if (email == null || email.isBlank() || !Boolean.TRUE.equals(emailVerified)) { + OAuth2Error error = + new OAuth2Error( + INVALID_USER_INFO_RESPONSE, "OAuth2 provider verified email is unavailable", null); + throw new OAuth2AuthenticationException(error, error.toString()); } - return String.valueOf(attribute); + return email.trim(); } - private String registrationId(Authentication authentication) { - if (authentication instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) { - return oauth2AuthenticationToken.getAuthorizedClientRegistrationId(); + private void requireGoogleAuthoritativeEmail(OAuth2User user, String email) { + if (!(user instanceof OidcUser oidcUser)) { + throw invalidUserInfo("Google OIDC user is required"); } - return "unknown"; + String hostedDomain = oidcUser.getIdToken().getClaimAsString("hd"); + if (!email.toLowerCase(Locale.ROOT).endsWith("@gmail.com") + && (hostedDomain == null || hostedDomain.isBlank())) { + throw invalidUserInfo("Google authoritative email is unavailable"); + } + } + + private OAuth2AuthenticationException invalidUserInfo(String description) { + OAuth2Error error = new OAuth2Error(INVALID_USER_INFO_RESPONSE, description, null); + return new OAuth2AuthenticationException(error, error.toString()); } - private record ProviderProfile( - String userId, - String login, - String email, - String githubUserId, - String githubLogin, - String googleUserId, - String googleEmail) {} + private String attribute(OAuth2User user, String name) { + Object value = user.getAttribute(name); + return value == null ? null : String.valueOf(value); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + throw new IllegalArgumentException("OAuth2 display name is missing"); + } + + private String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } } diff --git a/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java index 3dd5131..cdb264b 100644 --- a/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java +++ b/profanity-api/src/main/java/app/security/oauth2/OAuth2SecuritySupportConfig.java @@ -1,12 +1,22 @@ package app.security.oauth2; +import app.application.auth.SsoLoginCompletionService; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; @Configuration @EnableConfigurationProperties({SsoCookieProperties.class, SsoFrontendProperties.class}) public class OAuth2SecuritySupportConfig { + static final String LOCAL_STUB_SIGNING_KEY = "stub-sso-cookie-signing-key-for-local-and-ci"; + + @Bean + SsoCookieSecurityConfigurationValidator ssoCookieSecurityConfigurationValidator( + SsoCookieProperties properties, Environment environment) { + return new SsoCookieSecurityConfigurationValidator(properties, environment); + } @Bean CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository( @@ -15,12 +25,34 @@ CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepos } @Bean - OAuth2LoginSuccessHandler oauth2LoginSuccessHandler(SsoFrontendProperties ssoFrontendProperties) { - return new OAuth2LoginSuccessHandler(ssoFrontendProperties); + GitHubOAuth2UserService gitHubOAuth2UserService() { + return new GitHubOAuth2UserService(); + } + + @Bean + GoogleOidcUserService googleOidcUserService() { + return new GoogleOidcUserService(); + } + + @Bean + OAuth2LoginSuccessHandler oauth2LoginSuccessHandler( + SsoFrontendProperties ssoFrontendProperties, + SsoLoginCompletionService loginCompletionService) { + return new OAuth2LoginSuccessHandler(ssoFrontendProperties, loginCompletionService); } @Bean OAuth2LoginFailureHandler oauth2LoginFailureHandler(SsoFrontendProperties ssoFrontendProperties) { return new OAuth2LoginFailureHandler(ssoFrontendProperties); } + + static final class SsoCookieSecurityConfigurationValidator { + SsoCookieSecurityConfigurationValidator( + SsoCookieProperties properties, Environment environment) { + if (environment.acceptsProfiles(Profiles.of("prod")) + && LOCAL_STUB_SIGNING_KEY.equals(properties.signingKey())) { + throw new IllegalStateException("SSO_COOKIE_SIGNING_KEY must be configured in prod"); + } + } + } } diff --git a/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java b/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java index 5d14070..4586087 100644 --- a/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java +++ b/profanity-api/src/main/java/app/security/oauth2/SsoCookieProperties.java @@ -1,6 +1,12 @@ package app.security.oauth2; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +@Validated @ConfigurationProperties(prefix = "sso.cookie") -public record SsoCookieProperties(String name, long ttlSeconds, String signingKey) {} +public record SsoCookieProperties( + @NotBlank String name, @Min(1) long ttlSeconds, @NotBlank @Size(min = 32) String signingKey) {} diff --git a/profanity-api/src/main/resources/application-sso.yaml b/profanity-api/src/main/resources/application-sso.yaml index 68ccae0..c603511 100644 --- a/profanity-api/src/main/resources/application-sso.yaml +++ b/profanity-api/src/main/resources/application-sso.yaml @@ -36,3 +36,27 @@ sso: name: PF_OAUTH2_AUTHORIZATION_REQUEST ttl-seconds: 300 signing-key: ${SSO_COOKIE_SIGNING_KEY:stub-sso-cookie-signing-key-for-local-and-ci} + +security: + login: + exchange-code-ttl: ${LOGIN_EXCHANGE_CODE_TTL:60s} + refresh-token-ttl: ${LOGIN_REFRESH_TOKEN_TTL:14d} + absolute-session-ttl: ${LOGIN_ABSOLUTE_SESSION_TTL:30d} + refresh-reuse-grace: ${LOGIN_REFRESH_REUSE_GRACE:5s} + allowed-origins: ${SSO_FRONTEND_ALLOWED_ORIGINS:http://localhost:5173} + refresh-cookie: + name: ${LOGIN_REFRESH_COOKIE_NAME:PF_LOGIN_REFRESH} + secure: ${LOGIN_REFRESH_COOKIE_SECURE:false} + same-site: ${LOGIN_REFRESH_COOKIE_SAME_SITE:Strict} + path: /api/v1/auth + +app: + security: + jwt: + issuer: ${LOGIN_JWT_ISSUER:http://localhost:8080} + audience: ${LOGIN_JWT_AUDIENCE:profanity-dashboard} + access-token-ttl: ${LOGIN_JWT_ACCESS_TTL:15m} + clock-skew: ${LOGIN_JWT_CLOCK_SKEW:30s} + keys: + private-jwk: ${LOGIN_JWT_PRIVATE_JWK:} + public-jwk-set: ${LOGIN_JWT_PUBLIC_JWK_SET:} diff --git a/profanity-api/src/main/resources/openapi/authentication.md b/profanity-api/src/main/resources/openapi/authentication.md index 181bfd8..e5db14d 100644 --- a/profanity-api/src/main/resources/openapi/authentication.md +++ b/profanity-api/src/main/resources/openapi/authentication.md @@ -1,37 +1,90 @@ # Authentication +사람의 대시보드 로그인과 외부 API 호출 인증은 서로 다른 경계로 처리합니다. + +| 인증 타입 | Credential | 허용 범위 | +|---|---|---| +| `API_KEY` | `x-api-key` | 기존 필터, 클라이언트, 단어 관리, 동기화 API | +| `LOGIN_JWT` | `Authorization: Bearer {access_token}` | `GET /api/v1/auth/me`, `/api/v1/dashboard/**` | +| `OAUTH2_ACCESS_TOKEN` | 미래의 Bearer access token | 아직 미지원, 외부 API에서 HTTP `401`/code `4017`로 종료 | + +API Key와 `Authorization`을 동시에 보내거나 같은 인증 헤더를 중복 제출하면 HTTP `400`/code `4004`로 거부합니다. 로그인 JWT를 외부 API 인증으로 재사용할 수 없으며, 외부 API Bearer를 로그인 JWT로 추정하거나 fallback하지 않습니다. + ### API Key -보호된 API는 `x-api-key` 헤더가 필요합니다. +기존 외부 API는 다음 헤더를 사용합니다. ```http x-api-key: YOUR_SECRET_TOKEN ``` +기존 API Key의 성공, 권한, 오류 응답 계약은 유지됩니다. + +### Google/GitHub SSO 로그인 + +1. 브라우저를 `GET /oauth2/authorization/google` 또는 `GET /oauth2/authorization/github`로 이동합니다. +2. OAuth2 callback 성공 시 서버는 frontend 로그인 경로의 URL fragment에 60초 수명의 일회용 `code`만 전달합니다. +3. frontend는 `POST /api/v1/auth/exchange`의 JSON body로 코드를 한 번 교환합니다. +4. 응답 body의 RS256 access token은 메모리에서만 사용하고, opaque refresh token은 `HttpOnly` cookie로만 전달됩니다. + +```json +{ + "code": "ONE_TIME_EXCHANGE_CODE" +} +``` + +access token 기본 계약은 다음과 같습니다. + +- 수명: 15분, clock skew: 30초 +- 필수 검증: `alg=RS256`, `iss`, `aud`, `sub=users.id`, `iat`, `nbf`, `exp`, `jti` +- 용도 claim: `token_use=access`, `auth_type=LOGIN_JWT` +- authority: `AUTH_LOGIN_JWT`, `ROLE_USER` +- 매 요청마다 내부 사용자의 `ACTIVE` 상태를 다시 확인 + +### Refresh token rotation + +- `GET /api/v1/auth/csrf`에서 CSRF token을 받은 뒤 `POST /api/v1/auth/refresh`를 호출합니다. +- refresh 요청은 `PF_LOGIN_REFRESH` HttpOnly cookie와 CSRF header/cookie가 모두 필요합니다. +- 정상 rotation은 기존 refresh token을 즉시 소비하고 새 access token과 새 refresh cookie를 발급합니다. +- refresh token 원문은 저장하지 않고 SHA-256 hash만 MySQL에 저장합니다. +- refresh token 수명은 14일이며, 세션의 절대 수명은 최초 로그인부터 30일입니다. +- 소비된 token이 rotation 후 5초 grace 안에 중복 제출되면 그 요청만 실패하고 winner token family는 유지합니다. +- grace 이후 소비된 token이 재사용되면 replay로 판단해 해당 refresh session family 전체를 폐기합니다. +- 사용자 비활성화, token/session 만료, 폐기, 잘못된 token은 refresh cookie를 만료시킵니다. grace 안의 중복 요청은 winner cookie를 보호하기 위해 cookie를 삭제하지 않습니다. + +기본 refresh cookie 속성은 `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`입니다. 운영 프로필에서는 `Secure=true`가 아니면 애플리케이션이 시작되지 않습니다. 로그인 CORS는 명시적으로 허용된 frontend origin에만 credential을 허용합니다. + ### 인증 제외 경로 -- `GET /` -- `GET /index.html` +- `GET /`, `GET /index.html`, 정적 리소스 +- `GET /sso/**` +- `GET /oauth2/authorization/**`, `GET /login/oauth2/code/**` +- `POST /api/v1/auth/exchange` +- `GET /api/v1/auth/csrf` +- `POST /api/v1/auth/refresh` (refresh cookie와 CSRF로 자체 검증) - `POST /api/v1/clients/register` -- `GET /api/v1/clients/send-email` -- `PUT /api/v1/clients/send-email` -- `GET /api/v1/health` -- `GET /api/v1/ping` -- `GET /openapi.json` -- `GET /overview.md` -- `GET /llms.txt` -- `GET /llm.txt` +- `GET|PUT /api/v1/clients/send-email` +- `GET /api/v1/health`, `GET /api/v1/ping` +- `GET /openapi.json`, `GET /overview.md`, `GET /llms.txt`, `GET /llm.txt` ### 인증 실패 -| Code | 조건 | -|--------|------------------| -| `4010` | API Key 누락 | +| Code | 조건 | +|---|---| +| `4004` | 다중 또는 중복 credential 제출 | +| `4010` | 기존 외부 API의 API Key 누락 | +| `4011` | SSO 로그인 실패 | +| `4012` | 로그인 교환 코드가 잘못됐거나 만료·소비됨 | +| `4013` | 로그인 access token이 잘못됨 | +| `4014` | 로그인 access token 만료 | +| `4015` | refresh token/session이 잘못됐거나 만료·폐기됨 | +| `4016` | 소비된 refresh token 재사용 | +| `4017` | 외부 API용 OAuth2 access token 미지원 | | `4030` | 권한 부족, 차단, 폐기 상태 | -| `4031` | 클라이언트 정보 없음 | -| `4032` | API Key 유효하지 않음 | +| `4031` | API Key에 해당하는 클라이언트 정보 없음 | +| `4032` | API Key 유효하지 않음 | +| `4033` | 로그인 사용자 비활성 상태 | -### 예정 +### 의도적으로 미구현된 범위 -OAuth 2.0 Client Credentials 방식은 아직 제공하지 않습니다. -현재 운영 인증 방식은 API Key 헤더입니다. +OAuth2 Client Credentials의 `/oauth2/token`, `client_id/client_secret`, 외부 API access token 발급·검증은 아직 제공하지 않습니다. 현재 외부 API의 Bearer credential은 `OAUTH2_ACCESS_TOKEN` 확장 경계에서 fail-closed 처리합니다. diff --git a/profanity-api/src/main/resources/openapi/error-model.md b/profanity-api/src/main/resources/openapi/error-model.md index a288884..58105e2 100644 --- a/profanity-api/src/main/resources/openapi/error-model.md +++ b/profanity-api/src/main/resources/openapi/error-model.md @@ -4,7 +4,7 @@ OpenAPI의 `responses`는 HTTP 상태 코드를 기준으로 표시합니다. 이 API는 대부분의 실패를 HTTP `200`과 본문 `status.code`로 반환하므로, 각 operation에 HTTP `400`이나 `404`가 따로 표시되지 않을 수 있습니다. -API Key 누락처럼 보안 필터에서 차단되는 요청은 HTTP `401`과 `status.code = 4010`이 함께 반환될 수 있습니다. +기존 API Key 실패 계약은 HTTP `200`과 본문 code를 유지합니다. 새 로그인 인증 API와 credential 충돌은 실제 HTTP `400`/`401`/`403`과 해당 본문 code를 함께 반환합니다. ### 오류 응답 형식 @@ -26,11 +26,19 @@ API Key 누락처럼 보안 필터에서 차단되는 요청은 HTTP `401`과 `s | `4001` | `Invalid_callback_url` | callback URL 형식 오류 | | `4002` | `Invalid_tracking_id` | 유효하지 않은 tracking ID | | `4003` | `Not_fount_tracking_id` | tracking ID를 찾을 수 없음 | +| `4004` | `Ambiguous_credentials` | 다중 또는 중복 인증 정보 제출 | | `4010` | `Unauthorized` | API Key 누락 | | `4011` | `Oauth2_login_failed` | OAuth2 로그인 실패 | +| `4012` | `Login_code_invalid` | 로그인 교환 코드 오류, 만료 또는 재사용 | +| `4013` | `Login_token_invalid` | 로그인 access token 검증 실패 | +| `4014` | `Login_token_expired` | 로그인 access token 만료 | +| `4015` | `Refresh_token_invalid` | refresh token/session 오류 또는 만료 | +| `4016` | `Refresh_token_reused` | 소비된 refresh token 재사용 | +| `4017` | `Oauth2_access_token_unsupported` | 외부 API OAuth2 access token 미지원 | | `4030` | `Forbidden` | 권한 부족 또는 차단된 클라이언트 | | `4031` | `Not_found_client` | 클라이언트 정보 없음 | | `4032` | `Invalid_api_key` | 유효하지 않은 API Key | +| `4033` | `User_inactive` | 로그인 사용자 비활성 상태 | | `4290` | `Too_many_requests` | 요청 제한 초과 | | `5000` | `Internal_server_error` | 서버 내부 오류 | | `5030` | `Service_unavailable` | 서비스 점검 또는 일시 사용 불가 | diff --git a/profanity-api/src/test/java/app/e2e/AuthE2ETest.java b/profanity-api/src/test/java/app/e2e/AuthE2ETest.java new file mode 100644 index 0000000..dd858e9 --- /dev/null +++ b/profanity-api/src/test/java/app/e2e/AuthE2ETest.java @@ -0,0 +1,315 @@ +package app.e2e; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.application.auth.LoginAuthService; +import app.core.data.response.constant.StatusCode; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; + +class AuthE2ETest extends AbstractApiTester { + private static final String REFRESH_COOKIE = "PF_LOGIN_REFRESH"; + + @Autowired private MockMvc mockMvc; + @Autowired private LoginAuthService loginAuthService; + + @Test + @DisplayName("SSO 교환 코드로 access token과 HttpOnly refresh cookie를 발급한다") + void exchange_whenSsoCodeIsValid_issuesLoginTokens() throws Exception { + String code = issueExchangeCode("exchange-user"); + + MockHttpServletResponse exchange = exchange(code); + JsonNode body = body(exchange); + + assertThat(exchange.getStatus()).isEqualTo(200); + assertThat(exchange.getHeader(HttpHeaders.CACHE_CONTROL)).contains("no-store"); + assertThat(body.at("/status/code").asInt()).isEqualTo(StatusCode.OK.code()); + assertThat(body.at("/data/accessToken").asText()).isNotBlank(); + assertThat(body.at("/data/tokenType").asText()).isEqualTo("Bearer"); + assertThat(body.at("/data/expiresIn").asLong()).isPositive(); + assertThat(body.at("/data/user/id").asText()).isNotBlank(); + assertRefreshCookie(exchange.getCookie(REFRESH_COOKIE)); + + MockHttpServletResponse me = me(body.at("/data/accessToken").asText()); + assertThat(me.getStatus()).isEqualTo(200); + assertThat(body(me).at("/data/id").asText()).isEqualTo(body.at("/data/user/id").asText()); + } + + @Test + @DisplayName("refresh token을 rotate하면 기존 token은 grace 안에서 실패하지만 family는 유지한다") + void refresh_whenOldTokenIsReusedWithinGrace_keepsReplacementFamily() throws Exception { + MockHttpServletResponse exchange = exchange(issueExchangeCode("rotation-user")); + Cookie oldRefresh = exchange.getCookie(REFRESH_COOKIE); + Csrf csrf = csrf(oldRefresh); + + MockHttpServletResponse rotated = refresh(oldRefresh, csrf); + Cookie replacement = rotated.getCookie(REFRESH_COOKIE); + assertThat(rotated.getStatus()).isEqualTo(200); + assertRefreshCookie(replacement); + assertThat(replacement.getValue()).isNotEqualTo(oldRefresh.getValue()); + + MockHttpServletResponse duplicate = refresh(oldRefresh, csrf(oldRefresh)); + assertThat(duplicate.getStatus()).isEqualTo(401); + assertThat(body(duplicate).at("/status/code").asInt()) + .isEqualTo(StatusCode.REFRESH_TOKEN_REUSED.code()); + assertThat(duplicate.getHeader(HttpHeaders.SET_COOKIE)).isNull(); + + MockHttpServletResponse successorRotation = refresh(replacement, csrf(replacement)); + assertThat(successorRotation.getStatus()).isEqualTo(200); + assertThat(body(successorRotation).at("/data/accessToken").asText()).isNotBlank(); + } + + @Test + @DisplayName("refresh 요청에 CSRF token이 없으면 403을 반환한다") + void refresh_whenCsrfIsMissing_returnsForbidden() throws Exception { + Cookie refreshCookie = exchange(issueExchangeCode("csrf-user")).getCookie(REFRESH_COOKIE); + + MockHttpServletResponse response = + mockMvc + .perform(post("/api/v1/auth/refresh").cookie(refreshCookie)) + .andReturn() + .getResponse(); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(body(response).at("/status/code").asInt()).isEqualTo(StatusCode.FORBIDDEN.code()); + } + + @Test + @DisplayName("이미 소비한 로그인 교환 코드는 다시 사용할 수 없다") + void exchange_whenCodeIsReused_returnsUnauthorized() throws Exception { + String code = issueExchangeCode("reused-code-user"); + assertThat(exchange(code).getStatus()).isEqualTo(200); + + MockHttpServletResponse reused = exchange(code); + + assertThat(reused.getStatus()).isEqualTo(401); + assertThat(body(reused).at("/status/code").asInt()) + .isEqualTo(StatusCode.LOGIN_CODE_INVALID.code()); + } + + @Test + @DisplayName("외부 API의 Bearer token은 OAuth2 미지원 오류로 fail-closed 처리한다") + void externalApi_whenBearerIsSubmitted_returnsOAuth2Unsupported() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + post("/api/v1/filter") + .header(HttpHeaders.AUTHORIZATION, "Bearer unsupported-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"text\":\"hello\",\"mode\":\"QUICK\"}")) + .andReturn() + .getResponse(); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).at("/status/code").asInt()) + .isEqualTo(StatusCode.OAUTH2_ACCESS_TOKEN_UNSUPPORTED.code()); + } + + @Test + @DisplayName("API Key와 Authorization을 함께 보내면 요청을 거부한다") + void externalApi_whenMultipleCredentialsAreSubmitted_returnsBadRequest() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + post("/api/v1/filter") + .header("X-API-KEY", "one-key") + .header(HttpHeaders.AUTHORIZATION, "Bearer other-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"text\":\"hello\",\"mode\":\"QUICK\"}")) + .andReturn() + .getResponse(); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(body(response).at("/status/code").asInt()) + .isEqualTo(StatusCode.AMBIGUOUS_CREDENTIALS.code()); + } + + @Test + @DisplayName("로그인 교환 endpoint도 다중 credential을 거부하고 코드를 소비하지 않는다") + void exchange_whenMultipleCredentialsAreSubmitted_rejectsBeforeCodeConsumption() + throws Exception { + String code = issueExchangeCode("exchange-conflict-user"); + MockHttpServletResponse conflict = + mockMvc + .perform( + post("/api/v1/auth/exchange") + .header("X-API-KEY", "api-key") + .header(HttpHeaders.AUTHORIZATION, "Bearer login-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(java.util.Map.of("code", code)))) + .andReturn() + .getResponse(); + + assertThat(conflict.getStatus()).isEqualTo(400); + assertThat(body(conflict).at("/status/code").asInt()) + .isEqualTo(StatusCode.AMBIGUOUS_CREDENTIALS.code()); + assertThat(exchange(code).getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("대시보드 사용자 endpoint는 API Key 인증을 허용하지 않는다") + void me_whenApiKeyIsSubmitted_returnsLoginTokenInvalid() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform(get("/api/v1/auth/me").header("X-API-KEY", "legacy-api-key")) + .andReturn() + .getResponse(); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).at("/status/code").asInt()) + .isEqualTo(StatusCode.LOGIN_TOKEN_INVALID.code()); + } + + @Test + @DisplayName("허용된 frontend origin의 credentialed preflight를 허용한다") + void cors_whenAllowedFrontendRequestsPreflight_allowsCredentials() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + options("/api/v1/auth/refresh") + .header(HttpHeaders.ORIGIN, "http://localhost:5173") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "X-XSRF-TOKEN")) + .andExpect(status().isOk()) + .andReturn() + .getResponse(); + + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + .isEqualTo("http://localhost:5173"); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)).isEqualTo("true"); + } + + @Test + @DisplayName("허용되지 않은 frontend origin의 로그인 preflight를 거부한다") + void cors_whenFrontendOriginIsNotAllowed_rejectsCredentialedPreflight() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + options("/api/v1/auth/refresh") + .header(HttpHeaders.ORIGIN, "https://untrusted.example") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "X-XSRF-TOKEN")) + .andReturn() + .getResponse(); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull(); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)).isNull(); + } + + @Test + @DisplayName("기존 외부 API의 wildcard CORS 계약을 유지한다") + void cors_whenExternalApiRequestsPreflight_keepsLegacyWildcardPolicy() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + options("/api/v1/filter") + .header(HttpHeaders.ORIGIN, "https://client.example") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "X-API-KEY")) + .andExpect(status().isOk()) + .andReturn() + .getResponse(); + + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("*"); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)).isNull(); + } + + @Test + @DisplayName("대시보드 endpoint도 로그인 frontend origin에만 credential을 허용한다") + void cors_whenDashboardRequestsPreflight_usesLoginOriginPolicy() throws Exception { + MockHttpServletResponse response = + mockMvc + .perform( + options("/api/v1/dashboard/profile") + .header(HttpHeaders.ORIGIN, "http://localhost:5173") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization")) + .andExpect(status().isOk()) + .andReturn() + .getResponse(); + + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + .isEqualTo("http://localhost:5173"); + assertThat(response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)).isEqualTo("true"); + } + + private String issueExchangeCode(String providerUserId) { + return loginAuthService.issueExchangeCode( + new OAuthLoginProfile( + OAuthProvider.GOOGLE, + providerUserId, + providerUserId + "@gmail.com", + true, + true, + providerUserId + "@gmail.com", + providerUserId, + null)); + } + + private MockHttpServletResponse exchange(String code) throws Exception { + return mockMvc + .perform( + post("/api/v1/auth/exchange") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(java.util.Map.of("code", code)))) + .andReturn() + .getResponse(); + } + + private MockHttpServletResponse me(String accessToken) throws Exception { + return mockMvc + .perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andReturn() + .getResponse(); + } + + private Csrf csrf(Cookie refreshCookie) throws Exception { + MockHttpServletResponse response = + mockMvc.perform(get("/api/v1/auth/csrf").cookie(refreshCookie)).andReturn().getResponse(); + JsonNode body = body(response); + return new Csrf( + body.at("/data/headerName").asText(), + body.at("/data/token").asText(), + response.getCookie("XSRF-TOKEN")); + } + + private MockHttpServletResponse refresh(Cookie refreshCookie, Csrf csrf) throws Exception { + return mockMvc + .perform( + post("/api/v1/auth/refresh") + .cookie(refreshCookie, csrf.cookie()) + .header(csrf.headerName(), csrf.token())) + .andReturn() + .getResponse(); + } + + private JsonNode body(MockHttpServletResponse response) throws Exception { + return objectMapper.readTree(response.getContentAsString()); + } + + private void assertRefreshCookie(Cookie cookie) { + assertThat(cookie).isNotNull(); + assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.getSecure()).isFalse(); + assertThat(cookie.getPath()).isEqualTo("/api/v1/auth"); + assertThat(cookie.getMaxAge()).isPositive(); + assertThat(cookie.getAttribute("SameSite")).isEqualTo("Strict"); + assertThat(cookie.getDomain()).isNull(); + } + + private record Csrf(String headerName, String token, Cookie cookie) {} +} diff --git a/profanity-api/src/test/java/app/e2e/LoginAuthTransactionE2ETest.java b/profanity-api/src/test/java/app/e2e/LoginAuthTransactionE2ETest.java new file mode 100644 index 0000000..3675694 --- /dev/null +++ b/profanity-api/src/test/java/app/e2e/LoginAuthTransactionE2ETest.java @@ -0,0 +1,112 @@ +package app.e2e; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.application.auth.LoginAuthService; +import app.application.auth.LoginRefreshTokenService; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import app.security.login.SecureOpaqueTokenService; +import app.security.login.SecureOpaqueTokenService.OpaqueToken; +import java.time.Duration; +import java.time.Instant; +import javax.sql.DataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncodingException; + +@Import(LoginAuthTransactionE2ETest.FailingJwtEncoderConfig.class) +class LoginAuthTransactionE2ETest extends AbstractApiTester { + + @Autowired private LoginAuthService loginAuthService; + @Autowired private LoginRefreshTokenService refreshTokenService; + @Autowired private SecureOpaqueTokenService opaqueTokenService; + @Autowired private UserAccountRepository userRepository; + @Autowired private DataSource dataSource; + + @Test + @DisplayName("JWT 발급 실패 시 교환 코드 소비와 refresh session 생성을 rollback한다") + void exchange_whenJwtEncodingFails_rollsBackLoginState() { + String code = + loginAuthService.issueExchangeCode( + new OAuthLoginProfile( + OAuthProvider.GOOGLE, + "exchange-rollback-user", + "exchange-rollback@gmail.com", + true, + true, + "exchange-rollback@gmail.com", + "Exchange Rollback", + null)); + + assertThatThrownBy(() -> loginAuthService.exchange(code)) + .isInstanceOf(JwtEncodingException.class); + + assertThat(singleColumnIsNull("login_exchange_codes", "consumed_at")).isTrue(); + assertThat(countRows("login_refresh_sessions")).isZero(); + assertThat(countRows("login_refresh_tokens")).isZero(); + } + + @Test + @DisplayName("JWT 발급 실패 시 refresh rotation을 rollback하고 기존 token을 유지한다") + void refresh_whenJwtEncodingFails_rollsBackRotation() { + Instant now = Instant.now(); + UserAccount user = + userRepository.save( + UserAccount.create("Refresh Rollback", "refresh-rollback@example.com", null, now)); + OpaqueToken currentToken = opaqueTokenService.generate(); + refreshTokenService.createSession( + user.getId(), currentToken.hash(), now, Duration.ofDays(14), Duration.ofDays(30)); + + assertThatThrownBy(() -> loginAuthService.refresh(currentToken.plaintext())) + .isInstanceOf(JwtEncodingException.class); + + assertThat(countRows("login_refresh_tokens")).isEqualTo(1); + assertThat(singleColumnIsNull("login_refresh_tokens", "consumed_at")).isTrue(); + assertThat(singleColumnIsNull("login_refresh_sessions", "revoked_at")).isTrue(); + } + + private long countRows(String table) { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement("SELECT COUNT(*) FROM " + table); + var result = statement.executeQuery()) { + result.next(); + return result.getLong(1); + } catch (Exception exception) { + throw new IllegalStateException("Failed to count login auth rows", exception); + } + } + + private boolean singleColumnIsNull(String table, String column) { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement("SELECT " + column + " FROM " + table); + var result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + Object value = result.getObject(1); + assertThat(result.next()).isFalse(); + return value == null; + } catch (Exception exception) { + throw new IllegalStateException("Failed to read login auth state", exception); + } + } + + @TestConfiguration + static class FailingJwtEncoderConfig { + @Bean + @Primary + JwtEncoder failingLoginJwtEncoder() { + return parameters -> { + throw new JwtEncodingException("simulated login JWT encoding failure"); + }; + } + } +} diff --git a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java index 1dd07a3..e4eb127 100644 --- a/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java +++ b/profanity-api/src/test/java/app/e2e/OpenApiSpecE2ETest.java @@ -43,8 +43,18 @@ void openapiJson_whenRequested_returnsOpenApiSpec() throws Exception { assertThat(body.at("/components/securitySchemes/ApiKeyAuth/description").asText()) .as("ApiKeyAuth 보안 스키마는 x-api-key 헤더 설명을 제공해야 한다") .isEqualTo("클라이언트 등록 후 발급받은 API Key"); + assertThat(body.at("/components/securitySchemes/LoginJwtAuth/type").asText()).isEqualTo("http"); + assertThat(body.at("/components/securitySchemes/LoginJwtAuth/scheme").asText()) + .isEqualTo("bearer"); + assertThat(body.at("/components/securitySchemes/LoginJwtAuth/bearerFormat").asText()) + .isEqualTo("JWT"); assertThat(body.at("/paths/~1api~1v1~1filter/post").isMissingNode()).isFalse(); assertThat(body.at("/paths/~1api~1v1~1clients~1register/post").isMissingNode()).isFalse(); + assertThat(body.at("/paths/~1api~1v1~1auth~1exchange/post").isMissingNode()).isFalse(); + assertThat(body.at("/paths/~1api~1v1~1auth~1csrf/get").isMissingNode()).isFalse(); + assertThat(body.at("/paths/~1api~1v1~1auth~1refresh/post").isMissingNode()).isFalse(); + assertThat(body.at("/paths/~1api~1v1~1auth~1me/get/security/0/LoginJwtAuth").isArray()) + .isTrue(); assertThat(body.at("/paths/~1api~1v1~1health/get").isMissingNode()).isFalse(); assertThat(body.at("/paths/~1overview.md/get").isMissingNode()).isTrue(); assertThat(body.at("/paths/~1llms.txt/get").isMissingNode()).isTrue(); @@ -122,6 +132,10 @@ void openapiJson_whenPublicApiHasSuccessResponse_returnsConcreteSuccessResponseS new OperationPath("/paths/~1api~1v1~1clients~1send-email/put/responses/200/content"), new OperationPath("/paths/~1api~1v1~1filter/post/responses/200/content"), new OperationPath("/paths/~1api~1v1~1filter~1advanced/post/responses/200/content"), + new OperationPath("/paths/~1api~1v1~1auth~1exchange/post/responses/200/content"), + new OperationPath("/paths/~1api~1v1~1auth~1csrf/get/responses/200/content"), + new OperationPath("/paths/~1api~1v1~1auth~1refresh/post/responses/200/content"), + new OperationPath("/paths/~1api~1v1~1auth~1me/get/responses/200/content"), new OperationPath("/paths/~1api~1v1~1sync/get/responses/200/content"), new OperationPath( "/paths/~1api~1v1~1word~1accept~1{requestId}/post/responses/200/content") @@ -205,6 +219,87 @@ void openapiJson_whenFilterOpenApiAnnotationProvided_returnsRequestAndResponseEx .isEqualTo("advanced *****"); } + @Test + @DisplayName("로그인 API는 token, cookie, CSRF와 오류 응답 계약을 문서화한다") + void openapiJson_whenAuthOpenApiAnnotationProvided_returnsAuthenticationContract() + throws Exception { + // when + var response = mockMvcTester.get().uri("/openapi.json").exchange(); + + // then + assertThat(response).hasStatusOk(); + + JsonNode body = objectMapper.readTree(response.getResponse().getContentAsString()); + JsonNode exchange = body.at("/paths/~1api~1v1~1auth~1exchange/post"); + assertThat(exchange.at("/requestBody/content/application~1json/schema/$ref").asText()) + .isEqualTo("#/components/schemas/AuthCodeExchangeRequest"); + assertThat( + exchange + .at("/requestBody/content/application~1json/examples/exchangeCode/value/code") + .asText()) + .isEqualTo("sso_exchange_code_example"); + assertThat(exchange.at("/responses/200/headers/Set-Cookie").isMissingNode()).isFalse(); + assertThat( + exchange + .at( + "/responses/200/content/application~1json/examples/success/value/data/tokenType") + .asText()) + .isEqualTo("Bearer"); + assertThat( + exchange + .at( + "/responses/200/content/application~1json/examples/invalidRequest/value/status/code") + .asInt()) + .isEqualTo(4000); + assertThat( + exchange + .at( + "/responses/401/content/application~1json/examples/invalidCode/value/status/code") + .asInt()) + .isEqualTo(4012); + + JsonNode csrf = body.at("/paths/~1api~1v1~1auth~1csrf/get"); + assertThat(csrf.at("/responses/200/headers/Set-Cookie").isMissingNode()).isFalse(); + assertThat( + csrf.at( + "/responses/200/content/application~1json/examples/csrfToken/value/data/headerName") + .asText()) + .isEqualTo("X-XSRF-TOKEN"); + + JsonNode refresh = body.at("/paths/~1api~1v1~1auth~1refresh/post"); + assertThat( + findParameter(refresh.path("parameters"), "PF_LOGIN_REFRESH", "cookie").isMissingNode()) + .isFalse(); + assertThat(findParameter(refresh.path("parameters"), "XSRF-TOKEN", "cookie").isMissingNode()) + .isFalse(); + assertThat(findParameter(refresh.path("parameters"), "X-XSRF-TOKEN", "header").isMissingNode()) + .isFalse(); + assertThat( + refresh + .at( + "/responses/401/content/application~1json/examples/reusedToken/value/status/code") + .asInt()) + .isEqualTo(4016); + assertThat( + refresh + .at( + "/responses/403/content/application~1json/examples/csrfRejected/value/status/code") + .asInt()) + .isEqualTo(4030); + + JsonNode me = body.at("/paths/~1api~1v1~1auth~1me/get"); + assertThat(me.at("/security/0/LoginJwtAuth").isArray()).isTrue(); + assertThat( + me.at("/responses/200/content/application~1json/examples/currentUser/value/data/email") + .asText()) + .isEqualTo("user@example.com"); + assertThat( + me.at( + "/responses/401/content/application~1json/examples/invalidToken/value/status/code") + .asInt()) + .isEqualTo(4013); + } + @Test @DisplayName("응답 모델은 Scalar 모델 섹션에 표시할 설명을 가진다") void openapiJson_whenResponseSchemasRendered_returnsDescribedResponseProperties() diff --git a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java index dac7884..ff68784 100644 --- a/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java +++ b/profanity-api/src/test/java/app/presentation/SecurityAuthenticationTest.java @@ -1,26 +1,37 @@ package app.presentation; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.TestConfig; +import app.application.auth.SsoLoginCompletionService; import app.application.client.APIKeyGenerator; import app.core.data.constant.Mode; import app.core.data.response.constant.StatusCode; import app.dto.request.ApiRequest; import app.security.SecurityConfig; +import app.security.authentication.ApiKeyAuthenticator; import app.security.authentication.AuthenticationService; +import app.security.filter.AuthenticationRoutePolicy; +import app.security.filter.CustomAccessDeniedHandler; import app.security.filter.CustomAuthenticationEntryPoint; import app.security.filter.CustomAuthenticationFilter; +import app.security.filter.RequestCredentialResolver; +import app.security.login.LoginSessionProperties; import app.test.support.config.SecurityFakeStubConfig; import app.test.support.fake.FakeClientMetadataReader; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -32,8 +43,13 @@ SecurityConfig.class, CustomAuthenticationFilter.class, CustomAuthenticationEntryPoint.class, + CustomAccessDeniedHandler.class, AuthenticationService.class, - APIKeyGenerator.class + ApiKeyAuthenticator.class, + AuthenticationRoutePolicy.class, + RequestCredentialResolver.class, + APIKeyGenerator.class, + SecurityAuthenticationTest.SecurityTestConfig.class }) class SecurityAuthenticationTest { private static final String REQUEST_URL = "/api/v1/filter"; @@ -60,6 +76,43 @@ void test_200() throws Exception { .andExpect(status().isOk()); } + @Test + @DisplayName("대시보드에 로그인 JWT가 없으면 HTTP 401과 LOGIN_TOKEN_INVALID를 반환한다") + void authMe_missingLoginJwt_returnsUnauthorized() throws Exception { + mockMvc + .perform(get("/api/v1/auth/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status.code").value(StatusCode.LOGIN_TOKEN_INVALID.code())); + } + + @Test + @DisplayName("외부 API의 Bearer token은 HTTP 401과 OAuth2 미지원 코드를 반환한다") + void filter_bearerToken_returnsOauth2Unsupported() throws Exception { + mockMvc + .perform( + post(REQUEST_URL) + .header("Authorization", "Bearer future-oauth-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(quickRequest("test text")))) + .andExpect(status().isUnauthorized()) + .andExpect( + jsonPath("$.status.code").value(StatusCode.OAUTH2_ACCESS_TOKEN_UNSUPPORTED.code())); + } + + @Test + @DisplayName("API Key와 Authorization을 함께 제출하면 HTTP 400으로 거부한다") + void filter_multipleCredentials_returnsBadRequest() throws Exception { + mockMvc + .perform( + post(REQUEST_URL) + .header("X-API-KEY", "test-api-key") + .header("Authorization", "Bearer token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(quickRequest("test text")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status.code").value(StatusCode.AMBIGUOUS_CREDENTIALS.code())); + } + @Test @DisplayName("API 키가 비어있는 경우 4010 UNAUTHORIZED 응답을 반환한다") void test_4010() throws Exception { @@ -114,4 +167,23 @@ void test_4040() throws Exception { private static ApiRequest quickRequest(String text) { return new ApiRequest(text, Mode.QUICK, null); } + + @TestConfiguration + static class SecurityTestConfig { + @Bean + SsoLoginCompletionService ssoLoginCompletionService() { + return profile -> "unused-test-code"; + } + + @Bean + LoginSessionProperties loginSessionProperties() { + return new LoginSessionProperties( + Duration.ofMinutes(1), + Duration.ofDays(14), + Duration.ofDays(30), + Duration.ofSeconds(3), + List.of("http://localhost:5173"), + new LoginSessionProperties.RefreshCookie("refresh_token", false, "Lax", "/")); + } + } } diff --git a/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java b/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java new file mode 100644 index 0000000..e55f9b1 --- /dev/null +++ b/profanity-api/src/test/java/app/security/SecurityContextUtilTest.java @@ -0,0 +1,109 @@ +package app.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.domain.client.PermissionsType; +import app.security.authentication.ApiKeyPrincipal; +import app.security.authentication.AuthenticationType; +import app.security.authentication.CustomAuthentication; +import app.security.authentication.LoginUserPrincipal; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +class SecurityContextUtilTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("API Key 인증에서 클라이언트 ID와 credential을 명시적으로 조회한다") + void apiKeyAuthentication_whenRead_returnsApiClientValues() { + UUID clientId = UUID.randomUUID(); + ApiKeyPrincipal principal = + new ApiKeyPrincipal(clientId, "client@example.com", "test", List.of("READ"), "2026-07-11"); + setAuthentication( + new CustomAuthentication( + AuthenticationType.API_KEY, + "secret-api-key", + List.of( + new SimpleGrantedAuthority("AUTH_API_KEY"), + new SimpleGrantedAuthority("ROLE_READ")), + principal)); + + assertThat(SecurityContextUtil.isApiKeyAuthentication()).isTrue(); + assertThat(SecurityContextUtil.isLoginJwtAuthentication()).isFalse(); + assertThat(SecurityContextUtil.getCurrentApiClientId()).isEqualTo(clientId); + assertThat(SecurityContextUtil.getCurrentUserId()).isEqualTo(clientId); + assertThat(SecurityContextUtil.getCurrentApiKey()).isEqualTo("secret-api-key"); + assertThat(SecurityContextUtil.getCurrentUserPermissions()).containsExactly("READ"); + assertThat(SecurityContextUtil.isVerifiedClient()).isTrue(); + } + + @Test + @DisplayName("LOGIN_JWT 인증은 API 클라이언트 검증을 통과하지 않는다") + void loginJwtAuthentication_whenClientCheck_returnsFalse() { + UUID userId = UUID.randomUUID(); + setAuthentication( + new CustomAuthentication( + AuthenticationType.LOGIN_JWT, + null, + List.of( + new SimpleGrantedAuthority("AUTH_LOGIN_JWT"), + new SimpleGrantedAuthority("ROLE_USER")), + new LoginUserPrincipal(userId, "user@example.com"))); + + assertThat(SecurityContextUtil.isLoginJwtAuthentication()).isTrue(); + assertThat(SecurityContextUtil.getCurrentLoginUserId()).isEqualTo(userId); + assertThat(SecurityContextUtil.isVerifiedClient()).isFalse(); + assertThatThrownBy(SecurityContextUtil::getCurrentApiClientId) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("API key authentication"); + assertThatThrownBy(SecurityContextUtil::getCurrentApiKey) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("API key authentication"); + } + + @Test + @DisplayName("차단 권한은 API Key 주체에서만 평가한다") + void apiKeyAuthentication_withBlockedPermission_isBlocked() { + ApiKeyPrincipal principal = + new ApiKeyPrincipal( + UUID.randomUUID(), + "blocked@example.com", + "test", + List.of(PermissionsType.BLOCK.getValue()), + "2026-07-11"); + setAuthentication( + new CustomAuthentication(AuthenticationType.API_KEY, "redacted", List.of(), principal)); + + assertThat(SecurityContextUtil.isBlockedClient()).isTrue(); + assertThat(SecurityContextUtil.isVerifiedClient()).isFalse(); + } + + @Test + @DisplayName("인증 객체의 문자열에는 credential 원문이 포함되지 않는다") + void authentication_toString_redactsCredential() { + CustomAuthentication authentication = + new CustomAuthentication( + AuthenticationType.API_KEY, + "must-not-leak", + List.of(), + new ApiKeyPrincipal(UUID.randomUUID(), "client@example.com", "test", List.of(), "now")); + + assertThat(authentication.toString()).doesNotContain("must-not-leak"); + } + + private void setAuthentication(CustomAuthentication authentication) { + var context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } +} diff --git a/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java b/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java new file mode 100644 index 0000000..0f5c5d6 --- /dev/null +++ b/profanity-api/src/test/java/app/security/authentication/ApiKeyAuthenticatorTest.java @@ -0,0 +1,57 @@ +package app.security.authentication; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.application.client.MetadataReader; +import app.domain.client.ClientMetadata; +import app.security.filter.RequestCredential; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; + +class ApiKeyAuthenticatorTest { + + @Test + @DisplayName("API Key 검증 결과에 인증 타입 authority와 기존 권한을 함께 설정한다") + void authenticate_validApiKey_returnsTypedAuthenticationWithLegacyAuthorities() { + UUID clientId = UUID.randomUUID(); + MetadataReader metadataReader = + new MetadataReader() { + @Override + public ClientMetadata read(String apiKey) { + return new ClientMetadata( + clientId, + "client@example.com", + "test client", + null, + List.of("READ", "WRITE"), + "2026-07-11T00:00:00Z"); + } + + @Override + public String getApiKeyByEmail(String email) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean verifyClientByEmail(String email) { + throw new UnsupportedOperationException(); + } + }; + ApiKeyAuthenticator authenticator = new ApiKeyAuthenticator(metadataReader); + + Authentication authentication = + authenticator.authenticate( + new RequestCredential(AuthenticationType.API_KEY, "secret-api-key")); + + assertThat(authentication.getPrincipal()).isInstanceOf(ApiKeyPrincipal.class); + assertThat(((ApiKeyPrincipal) authentication.getPrincipal()).id()).isEqualTo(clientId); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .containsExactly("AUTH_API_KEY", "ROLE_READ", "ROLE_WRITE"); + assertThat(authentication.getCredentials()).isEqualTo("secret-api-key"); + assertThat(authentication.toString()).doesNotContain("secret-api-key"); + } +} diff --git a/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java b/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java new file mode 100644 index 0000000..aa142df --- /dev/null +++ b/profanity-api/src/test/java/app/security/authentication/AuthenticationServiceTest.java @@ -0,0 +1,114 @@ +package app.security.authentication; + +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static app.core.data.response.constant.StatusCode.OAUTH2_ACCESS_TOKEN_UNSUPPORTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.security.filter.AuthenticationRoutePolicy; +import app.security.filter.RequestCredential; +import app.security.filter.RequestCredentialResolver; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; + +class AuthenticationServiceTest { + + @Test + @DisplayName("대시보드 JWT 검증 실패 후 API Key 인증기로 fallback하지 않는다") + void getAuthentication_loginJwtFailure_doesNotFallbackToApiKey() { + CountingAuthenticator loginAuthenticator = + new CountingAuthenticator(AuthenticationType.LOGIN_JWT, true); + CountingAuthenticator apiKeyAuthenticator = + new CountingAuthenticator(AuthenticationType.API_KEY, false); + AuthenticationService service = service(loginAuthenticator, apiKeyAuthenticator); + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer invalid-login-token"); + + assertThatThrownBy(() -> service.getAuthentication(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(LOGIN_TOKEN_INVALID.stringCode()); + assertThat(loginAuthenticator.invocationCount).isEqualTo(1); + assertThat(apiKeyAuthenticator.invocationCount).isZero(); + } + + @Test + @DisplayName("외부 API Bearer token은 OAuth2 미지원 오류로 fail-closed 처리한다") + void getAuthentication_externalBearer_throwsOauth2Unsupported() { + CountingAuthenticator loginAuthenticator = + new CountingAuthenticator(AuthenticationType.LOGIN_JWT, false); + CountingAuthenticator apiKeyAuthenticator = + new CountingAuthenticator(AuthenticationType.API_KEY, false); + AuthenticationService service = service(loginAuthenticator, apiKeyAuthenticator); + MockHttpServletRequest request = request("POST", "/api/v1/filter"); + request.addHeader("Authorization", "Bearer future-token"); + + assertThatThrownBy(() -> service.getAuthentication(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(OAUTH2_ACCESS_TOKEN_UNSUPPORTED.stringCode()); + assertThat(loginAuthenticator.invocationCount).isZero(); + assertThat(apiKeyAuthenticator.invocationCount).isZero(); + } + + @Test + @DisplayName("동일 인증 타입의 인증기가 둘이면 애플리케이션 구성을 거부한다") + void constructor_duplicateAuthenticatorType_throwsIllegalStateException() { + RequestCredentialResolver resolver = + new RequestCredentialResolver(new AuthenticationRoutePolicy()); + + assertThatThrownBy( + () -> + new AuthenticationService( + resolver, + List.of( + new CountingAuthenticator(AuthenticationType.API_KEY, false), + new CountingAuthenticator(AuthenticationType.API_KEY, false)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(AuthenticationType.API_KEY.name()); + } + + private AuthenticationService service(RequestAuthenticator... authenticators) { + return new AuthenticationService( + new RequestCredentialResolver(new AuthenticationRoutePolicy()), List.of(authenticators)); + } + + private MockHttpServletRequest request(String method, String uri) { + return new MockHttpServletRequest(method, uri); + } + + private static final class CountingAuthenticator implements RequestAuthenticator { + private final AuthenticationType type; + private final boolean fail; + private int invocationCount; + + private CountingAuthenticator(AuthenticationType type, boolean fail) { + this.type = type; + this.fail = fail; + } + + @Override + public AuthenticationType supports() { + return type; + } + + @Override + public Authentication authenticate(RequestCredential credential) { + invocationCount++; + if (fail) { + throw new CredentialAuthenticationException(HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } + return new CustomAuthentication( + type, + null, + List.of(), + type == AuthenticationType.LOGIN_JWT + ? new LoginUserPrincipal(UUID.randomUUID(), "user@example.com") + : new ApiKeyPrincipal( + UUID.randomUUID(), "client@example.com", "test", List.of("READ"), "now")); + } + } +} diff --git a/profanity-api/src/test/java/app/security/filter/CredentialConflictFilterTest.java b/profanity-api/src/test/java/app/security/filter/CredentialConflictFilterTest.java new file mode 100644 index 0000000..aec0821 --- /dev/null +++ b/profanity-api/src/test/java/app/security/filter/CredentialConflictFilterTest.java @@ -0,0 +1,89 @@ +package app.security.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.core.data.response.constant.StatusCode; +import jakarta.servlet.DispatcherType; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.ModelAndView; + +class CredentialConflictFilterTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("public auth endpoint에서도 다중 credential을 거부하고 요청 처리를 중단한다") + void doFilter_publicAuthWithMultipleCredentials_rejectsConflict() throws Exception { + RequestCredentialResolver resolver = + new RequestCredentialResolver(new AuthenticationRoutePolicy()); + CustomAuthenticationEntryPoint entryPoint = + new CustomAuthenticationEntryPoint( + (request, response, handler, exception) -> new ModelAndView()); + CredentialConflictFilter filter = new CredentialConflictFilter(resolver, entryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/exchange"); + request.addHeader(RequestCredentialResolver.API_KEY_HEADER, "api-key"); + request.addHeader("Authorization", "Bearer login-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean filterChainCalled = new AtomicBoolean(); + + filter.doFilter( + request, response, (chainRequest, chainResponse) -> filterChainCalled.set(true)); + + assertThat(filterChainCalled).isFalse(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(request.getAttribute("exception").toString()) + .contains(StatusCode.AMBIGUOUS_CREDENTIALS.stringCode()); + } + + @Test + @DisplayName("단일 또는 없는 credential은 public auth endpoint로 전달한다") + void doFilter_publicAuthWithoutConflict_continuesChain() throws Exception { + CredentialConflictFilter filter = + new CredentialConflictFilter( + new RequestCredentialResolver(new AuthenticationRoutePolicy()), null); + List requests = + List.of( + new MockHttpServletRequest("POST", "/api/v1/auth/exchange"), + requestWithHeader("X-API-KEY", "api-key"), + requestWithHeader("Authorization", "Bearer login-token")); + + for (MockHttpServletRequest request : requests) { + AtomicBoolean filterChainCalled = new AtomicBoolean(); + filter.doFilter( + request, + new MockHttpServletResponse(), + (chainRequest, chainResponse) -> filterChainCalled.set(true)); + assertThat(filterChainCalled).isTrue(); + } + } + + @Test + @DisplayName("preflight와 ERROR dispatch는 credential 충돌 검사에서 제외한다") + void shouldNotFilter_preflightAndErrorDispatch_returnsTrue() { + CredentialConflictFilter filter = new CredentialConflictFilter(null, null); + MockHttpServletRequest preflight = + new MockHttpServletRequest("OPTIONS", "/api/v1/auth/refresh"); + MockHttpServletRequest error = new MockHttpServletRequest("GET", "/error"); + error.setDispatcherType(DispatcherType.ERROR); + + assertThat(filter.shouldNotFilter(preflight)).isTrue(); + assertThat(filter.shouldNotFilter(error)).isTrue(); + } + + private MockHttpServletRequest requestWithHeader(String name, String value) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/exchange"); + request.addHeader(name, value); + return request; + } +} diff --git a/profanity-api/src/test/java/app/security/filter/CustomAuthenticationFilterTest.java b/profanity-api/src/test/java/app/security/filter/CustomAuthenticationFilterTest.java new file mode 100644 index 0000000..f462eab --- /dev/null +++ b/profanity-api/src/test/java/app/security/filter/CustomAuthenticationFilterTest.java @@ -0,0 +1,177 @@ +package app.security.filter; + +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.security.authentication.AuthenticationService; +import app.security.authentication.AuthenticationType; +import app.security.authentication.CredentialAuthenticationException; +import app.security.authentication.CustomAuthentication; +import app.security.authentication.LoginUserPrincipal; +import app.security.authentication.RequestAuthenticator; +import jakarta.servlet.DispatcherType; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.ModelAndView; + +class CustomAuthenticationFilterTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("인증 실패 시 SecurityContext를 비우고 다음 filter를 호출하지 않는다") + void doFilter_authenticationFails_clearsContextAndStopsChain() throws Exception { + RequestAuthenticator failingAuthenticator = + new RequestAuthenticator() { + @Override + public AuthenticationType supports() { + return AuthenticationType.LOGIN_JWT; + } + + @Override + public org.springframework.security.core.Authentication authenticate( + RequestCredential credential) { + throw new CredentialAuthenticationException( + HttpStatus.UNAUTHORIZED, LOGIN_TOKEN_INVALID); + } + }; + RequestCredentialResolver credentialResolver = + new RequestCredentialResolver(new AuthenticationRoutePolicy()); + AuthenticationService authenticationService = + new AuthenticationService(credentialResolver, List.of(failingAuthenticator)); + CustomAuthenticationEntryPoint entryPoint = + new CustomAuthenticationEntryPoint( + (request, response, handler, exception) -> new ModelAndView()); + CustomAuthenticationFilter filter = + new CustomAuthenticationFilter(authenticationService, entryPoint); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean filterChainCalled = new AtomicBoolean(); + + filter.doFilter( + request, response, (chainRequest, chainResponse) -> filterChainCalled.set(true)); + + assertThat(filterChainCalled).isFalse(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + @DisplayName("정상 LOGIN_JWT 인증은 빈 SecurityContext에 정확히 한 인증 객체를 설정한다") + void doFilter_validLoginJwt_setsSingleAuthenticationAndContinuesChain() throws Exception { + UUID userId = UUID.randomUUID(); + RequestAuthenticator loginAuthenticator = + new RequestAuthenticator() { + @Override + public AuthenticationType supports() { + return AuthenticationType.LOGIN_JWT; + } + + @Override + public org.springframework.security.core.Authentication authenticate( + RequestCredential credential) { + return new CustomAuthentication( + AuthenticationType.LOGIN_JWT, + null, + List.of(new SimpleGrantedAuthority("AUTH_LOGIN_JWT")), + new LoginUserPrincipal(userId, "user@example.com")); + } + }; + AuthenticationService authenticationService = + new AuthenticationService( + new RequestCredentialResolver(new AuthenticationRoutePolicy()), + List.of(loginAuthenticator)); + CustomAuthenticationFilter filter = new CustomAuthenticationFilter(authenticationService, null); + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer valid-token"); + AtomicReference observed = + new AtomicReference<>(); + + filter.doFilter( + request, + new MockHttpServletResponse(), + (chainRequest, chainResponse) -> + observed.set(SecurityContextHolder.getContext().getAuthentication())); + + assertThat(observed.get()).isInstanceOf(CustomAuthentication.class); + assertThat(((CustomAuthentication) observed.get()).authenticationType()) + .isEqualTo(AuthenticationType.LOGIN_JWT); + assertThat(((LoginUserPrincipal) observed.get().getPrincipal()).id()).isEqualTo(userId); + } + + @Test + @DisplayName("로그인 교환과 갱신 endpoint만 API Key filter에서 정확히 제외한다") + void shouldNotFilter_publicAuthEndpoints_excludesOnlyExactRoutes() { + CustomAuthenticationFilter filter = new CustomAuthenticationFilter(null, null); + + assertThat(filter.shouldNotFilter(request("POST", "/api/v1/auth/exchange"))).isTrue(); + assertThat(filter.shouldNotFilter(request("GET", "/api/v1/auth/csrf"))).isTrue(); + assertThat(filter.shouldNotFilter(request("POST", "/api/v1/auth/refresh"))).isTrue(); + assertThat(filter.shouldNotFilter(request("GET", "/api/v1/auth/me"))).isFalse(); + assertThat(filter.shouldNotFilter(request("POST", "/api/v1/auth/exchange-extra"))).isFalse(); + assertThat(filter.shouldNotFilter(request("GET", "/api/v1/auth/refresh"))).isFalse(); + } + + @Test + @DisplayName("CORS preflight와 ERROR dispatch는 인증 filter에서 제외한다") + void shouldNotFilter_preflightAndErrorDispatch_returnsTrue() { + CustomAuthenticationFilter filter = new CustomAuthenticationFilter(null, null); + MockHttpServletRequest preflight = request("OPTIONS", "/api/v1/auth/me"); + MockHttpServletRequest error = request("GET", "/error"); + error.setDispatcherType(DispatcherType.ERROR); + + assertThat(filter.shouldNotFilter(preflight)).isTrue(); + assertThat(filter.shouldNotFilter(error)).isTrue(); + } + + @Test + @DisplayName("예상하지 못한 일반 예외는 인증 오류로 숨기지 않고 전파한다") + void doFilter_unexpectedException_propagatesException() { + RequestAuthenticator brokenAuthenticator = + new RequestAuthenticator() { + @Override + public AuthenticationType supports() { + return AuthenticationType.LOGIN_JWT; + } + + @Override + public org.springframework.security.core.Authentication authenticate( + RequestCredential credential) { + throw new IllegalStateException("unexpected failure"); + } + }; + AuthenticationService authenticationService = + new AuthenticationService( + new RequestCredentialResolver(new AuthenticationRoutePolicy()), + List.of(brokenAuthenticator)); + CustomAuthenticationFilter filter = new CustomAuthenticationFilter(authenticationService, null); + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer token"); + + assertThatThrownBy( + () -> + filter.doFilter( + request, new MockHttpServletResponse(), (chainRequest, chainResponse) -> {})) + .isInstanceOf(IllegalStateException.class) + .hasMessage("unexpected failure"); + } + + private MockHttpServletRequest request(String method, String uri) { + return new MockHttpServletRequest(method, uri); + } +} diff --git a/profanity-api/src/test/java/app/security/filter/RequestCredentialResolverTest.java b/profanity-api/src/test/java/app/security/filter/RequestCredentialResolverTest.java new file mode 100644 index 0000000..1b02edd --- /dev/null +++ b/profanity-api/src/test/java/app/security/filter/RequestCredentialResolverTest.java @@ -0,0 +1,130 @@ +package app.security.filter; + +import static app.core.data.response.constant.StatusCode.AMBIGUOUS_CREDENTIALS; +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static app.core.data.response.constant.StatusCode.UNAUTHORIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.security.authentication.AuthenticationType; +import app.security.authentication.CredentialAuthenticationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.BadCredentialsException; + +class RequestCredentialResolverTest { + private final RequestCredentialResolver resolver = + new RequestCredentialResolver(new AuthenticationRoutePolicy()); + + @Test + @DisplayName("외부 API의 API Key를 API_KEY 타입으로 해석한다") + void resolve_externalApiWithApiKey_returnsApiKeyCredential() { + MockHttpServletRequest request = externalApiRequest(); + request.addHeader(RequestCredentialResolver.API_KEY_HEADER, "test-api-key"); + + RequestCredential credential = resolver.resolve(request); + + assertThat(credential.type()).isEqualTo(AuthenticationType.API_KEY); + assertThat(credential.value()).isEqualTo("test-api-key"); + assertThat(credential.toString()).doesNotContain("test-api-key"); + } + + @Test + @DisplayName("대시보드 Bearer token을 LOGIN_JWT 타입으로 해석한다") + void resolve_dashboardWithBearer_returnsLoginJwtCredential() { + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer login-token"); + + RequestCredential credential = resolver.resolve(request); + + assertThat(credential.type()).isEqualTo(AuthenticationType.LOGIN_JWT); + assertThat(credential.value()).isEqualTo("login-token"); + } + + @Test + @DisplayName("외부 API의 Bearer token을 미구현 OAuth2 타입으로 분류한다") + void resolve_externalApiWithBearer_returnsOauth2Credential() { + MockHttpServletRequest request = externalApiRequest(); + request.addHeader("Authorization", "Bearer future-oauth-token"); + + RequestCredential credential = resolver.resolve(request); + + assertThat(credential.type()).isEqualTo(AuthenticationType.OAUTH2_ACCESS_TOKEN); + } + + @Test + @DisplayName("API Key와 Authorization을 동시에 제출하면 거부한다") + void resolve_multipleCredentialTypes_throwsAmbiguousCredentials() { + MockHttpServletRequest request = externalApiRequest(); + request.addHeader(RequestCredentialResolver.API_KEY_HEADER, "test-api-key"); + request.addHeader("Authorization", "Bearer token"); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(AMBIGUOUS_CREDENTIALS.stringCode()); + } + + @Test + @DisplayName("Authorization 헤더가 중복되면 거부한다") + void resolve_duplicateAuthorizationHeaders_throwsAmbiguousCredentials() { + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer first"); + request.addHeader("Authorization", "Bearer second"); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(AMBIGUOUS_CREDENTIALS.stringCode()); + } + + @Test + @DisplayName("값이 없는 Bearer scheme은 거부한다") + void resolve_emptyBearer_throwsLoginTokenInvalid() { + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Bearer "); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("대시보드의 잘못된 Authorization scheme은 거부한다") + void resolve_dashboardWithWrongScheme_throwsLoginTokenInvalid() { + MockHttpServletRequest request = request("GET", "/api/v1/auth/me"); + request.addHeader("Authorization", "Basic credentials"); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("대시보드에 API Key를 제출하면 로그인 인증으로 대체하지 않는다") + void resolve_dashboardWithApiKey_throwsLoginTokenInvalid() { + MockHttpServletRequest request = request("GET", "/api/v1/dashboard/profile"); + request.addHeader(RequestCredentialResolver.API_KEY_HEADER, "test-api-key"); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("외부 API에 인증 정보가 없으면 기존 UNAUTHORIZED 코드를 유지한다") + void resolve_externalApiWithoutCredential_throwsLegacyUnauthorized() { + MockHttpServletRequest request = externalApiRequest(); + + assertThatThrownBy(() -> resolver.resolve(request)) + .isInstanceOf(BadCredentialsException.class) + .hasMessage(UNAUTHORIZED.stringCode()); + } + + private MockHttpServletRequest externalApiRequest() { + return request("POST", "/api/v1/filter"); + } + + private MockHttpServletRequest request(String method, String uri) { + return new MockHttpServletRequest(method, uri); + } +} diff --git a/profanity-api/src/test/java/app/security/jwt/JwtKeyConfigTest.java b/profanity-api/src/test/java/app/security/jwt/JwtKeyConfigTest.java new file mode 100644 index 0000000..c6ac5e8 --- /dev/null +++ b/profanity-api/src/test/java/app/security/jwt/JwtKeyConfigTest.java @@ -0,0 +1,104 @@ +package app.security.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +class JwtKeyConfigTest { + + private final JwtKeyConfig config = new JwtKeyConfig(); + + @Test + @DisplayName("운영 환경에 로그인 JWT private JWK가 없으면 시작을 거부한다") + void jwtKeyMaterial_whenPrivateJwkIsMissingInProduction_throwsIllegalStateException() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("prod"); + + assertThatThrownBy(() -> config.jwtKeyMaterial(new JwtKeyProperties("", ""), environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("LOGIN_JWT_PRIVATE_JWK"); + } + + @Test + @DisplayName("현재와 이전 public JWK를 함께 설정하면 rotation 이전 token도 검증한다") + void loginJwtDecoder_whenPreviousPublicKeyIsConfigured_verifiesBothKeyGenerations() + throws Exception { + RSAKey current = new RSAKeyGenerator(2048).keyID("current-key").generate(); + RSAKey previous = new RSAKeyGenerator(2048).keyID("previous-key").generate(); + String publicJwkSet = + new JWKSet(List.of(current.toPublicJWK(), previous.toPublicJWK())).toString(false); + JwtKeyConfig.JwtKeyMaterial material = + config.jwtKeyMaterial( + new JwtKeyProperties(current.toJSONString(), publicJwkSet), new MockEnvironment()); + + String currentToken = encode(config.loginJwtEncoder(material), "current-key"); + String previousToken = + encode(new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(previous))), "previous-key"); + + assertThat(config.loginJwtDecoder(material).decode(currentToken).getSubject()) + .isEqualTo("current-user"); + assertThat(config.loginJwtDecoder(material).decode(previousToken).getSubject()) + .isEqualTo("previous-user"); + } + + @Test + @DisplayName("검증 JWK set에 현재 signing key가 없으면 구성을 거부한다") + void jwtKeyMaterial_whenPublicSetOmitsActiveKey_throwsIllegalStateException() throws Exception { + RSAKey current = new RSAKeyGenerator(2048).keyID("current-key").generate(); + RSAKey previous = new RSAKeyGenerator(2048).keyID("previous-key").generate(); + + assertThatThrownBy( + () -> + config.jwtKeyMaterial( + new JwtKeyProperties( + current.toJSONString(), new JWKSet(previous.toPublicJWK()).toString(false)), + new MockEnvironment())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active private key kid"); + } + + @Test + @DisplayName("검증 JWK의 kid만 같고 RSA key material이 다르면 구성을 거부한다") + void jwtKeyMaterial_whenPublicSetReusesKidWithDifferentKey_throwsIllegalStateException() + throws Exception { + RSAKey current = new RSAKeyGenerator(2048).keyID("active-key").generate(); + RSAKey mismatched = new RSAKeyGenerator(2048).keyID("active-key").generate(); + + assertThatThrownBy( + () -> + config.jwtKeyMaterial( + new JwtKeyProperties( + current.toJSONString(), + new JWKSet(mismatched.toPublicJWK()).toString(false)), + new MockEnvironment())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active private key kid"); + } + + private String encode(JwtEncoder encoder, String keyId) { + Instant now = Instant.now(); + JwtClaimsSet claims = + JwtClaimsSet.builder() + .subject(keyId.replace("key", "user")) + .issuedAt(now) + .expiresAt(now.plusSeconds(60)) + .build(); + JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).keyId(keyId).type("JWT").build(); + return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + } +} diff --git a/profanity-api/src/test/java/app/security/jwt/LoginJwtServiceTest.java b/profanity-api/src/test/java/app/security/jwt/LoginJwtServiceTest.java new file mode 100644 index 0000000..94f6a01 --- /dev/null +++ b/profanity-api/src/test/java/app/security/jwt/LoginJwtServiceTest.java @@ -0,0 +1,273 @@ +package app.security.jwt; + +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_EXPIRED; +import static app.core.data.response.constant.StatusCode.LOGIN_TOKEN_INVALID; +import static app.core.data.response.constant.StatusCode.USER_INACTIVE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import app.security.authentication.AuthenticationType; +import app.security.authentication.CredentialAuthenticationException; +import app.security.authentication.CustomAuthentication; +import app.security.authentication.LoginUserPrincipal; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +class LoginJwtServiceTest { + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); + private static final String ISSUER = "https://api.example.test"; + private static final String AUDIENCE = "profanity-dashboard"; + + private final InMemoryUserAccountRepository userAccountRepository = + new InMemoryUserAccountRepository(); + private JwtEncoder jwtEncoder; + private JwtDecoder jwtDecoder; + private LoginJwtService loginJwtService; + + @BeforeEach + void setUp() throws Exception { + RSAKey rsaKey = new RSAKeyGenerator(2048).keyID("test-key").generate(); + jwtEncoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(rsaKey))); + jwtDecoder = + NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()) + .signatureAlgorithm(SignatureAlgorithm.RS256) + .build(); + JwtSecurityProperties properties = + new JwtSecurityProperties(ISSUER, AUDIENCE, Duration.ofMinutes(15), Duration.ofSeconds(30)); + loginJwtService = + new LoginJwtService( + jwtEncoder, + jwtDecoder, + properties, + userAccountRepository, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + @DisplayName("활성 사용자에게 필수 claim을 가진 RS256 access token을 발급한다") + void issue_activeUser_returnsRs256AccessTokenWithRequiredClaims() { + UserAccount user = activeUser(); + + IssuedAccessToken issued = loginJwtService.issue(user); + Jwt jwt = jwtDecoder.decode(issued.token()); + + assertThat(jwt.getHeaders().get("alg")).hasToString("RS256"); + assertThat(jwt.getClaimAsString("iss")).isEqualTo(ISSUER); + assertThat(jwt.getAudience()).containsExactly(AUDIENCE); + assertThat(jwt.getSubject()).isEqualTo(user.getId().toString()); + assertThat(jwt.getIssuedAt()).isEqualTo(NOW); + assertThat(jwt.getNotBefore()).isEqualTo(NOW); + assertThat(jwt.getExpiresAt()).isEqualTo(NOW.plus(Duration.ofMinutes(15))); + assertThat(jwt.getId()).isNotBlank(); + assertThat(jwt.getClaimAsString("token_use")).isEqualTo("access"); + assertThat(jwt.getClaimAsString("auth_type")).isEqualTo("LOGIN_JWT"); + assertThat(issued.toString()).doesNotContain(issued.token()); + } + + @Test + @DisplayName("정상 access token은 최신 사용자 정보로 LOGIN_JWT Security Authentication을 생성한다") + void authenticate_validAccessToken_returnsLoginJwtAuthentication() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = loginJwtService.issue(user).token(); + + Authentication authentication = loginJwtService.authenticate(token); + + assertThat(authentication).isInstanceOf(CustomAuthentication.class); + assertThat(((CustomAuthentication) authentication).authenticationType()) + .isEqualTo(AuthenticationType.LOGIN_JWT); + assertThat(authentication.getPrincipal()).isInstanceOf(LoginUserPrincipal.class); + assertThat(((LoginUserPrincipal) authentication.getPrincipal()).id()).isEqualTo(user.getId()); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .containsExactly("AUTH_LOGIN_JWT", "ROLE_USER"); + assertThat(authentication.getCredentials()).isNull(); + assertThat(authentication.toString()).doesNotContain(token); + } + + @Test + @DisplayName("서명이 변조된 token은 거부한다") + void authenticate_tamperedToken_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = loginJwtService.issue(user).token(); + String[] parts = token.split("\\."); + parts[2] = (parts[2].startsWith("A") ? "B" : "A") + parts[2].substring(1); + String tampered = String.join(".", parts); + + assertInvalidToken(tampered, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("만료된 access token은 만료 코드로 거부한다") + void authenticate_expiredToken_throwsLoginTokenExpired() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = + encode( + claims(user.getId()) + .issuedAt(NOW.minus(Duration.ofMinutes(20))) + .notBefore(NOW.minus(Duration.ofMinutes(20))) + .expiresAt(NOW.minus(Duration.ofMinutes(5))) + .build()); + + assertInvalidToken(token, LOGIN_TOKEN_EXPIRED.stringCode()); + } + + @Test + @DisplayName("issuer가 다른 token은 거부한다") + void authenticate_wrongIssuer_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claims(user.getId()).issuer("https://other.example.test").build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("audience가 다른 token은 거부한다") + void authenticate_wrongAudience_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claims(user.getId()).audience(List.of("other-audience")).build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("refresh 용도의 token을 access 인증에 사용할 수 없다") + void authenticate_refreshTokenUse_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claims(user.getId()).claim("token_use", "refresh").build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("LOGIN_JWT가 아닌 auth_type token은 거부한다") + void authenticate_wrongAuthenticationType_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claims(user.getId()).claim("auth_type", "API_KEY").build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("jti가 없는 token은 거부한다") + void authenticate_missingJti_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claimsWithoutId(user.getId()).build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("허용 clock skew보다 미래인 nbf token은 거부한다") + void authenticate_notBeforeBeyondClockSkew_throwsLoginTokenInvalid() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = encode(claims(user.getId()).notBefore(NOW.plusSeconds(31)).build()); + + assertInvalidToken(token, LOGIN_TOKEN_INVALID.stringCode()); + } + + @Test + @DisplayName("token 검증 시 사용자가 비활성 상태면 SecurityContext 인증을 만들지 않는다") + void authenticate_inactiveUser_throwsUserInactive() { + UserAccount user = userAccountRepository.save(activeUser()); + String token = loginJwtService.issue(user).token(); + user.disable(NOW.plusSeconds(1)); + + assertInvalidToken(token, USER_INACTIVE.stringCode()); + } + + @Test + @DisplayName("비활성 사용자에게 access token을 발급하지 않는다") + void issue_inactiveUser_throwsUserInactive() { + UserAccount user = activeUser(); + user.disable(NOW); + + assertThatThrownBy(() -> loginJwtService.issue(user)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(USER_INACTIVE.stringCode()); + } + + private JwtClaimsSet.Builder claims(UUID userId) { + return claimsWithoutId(userId).id(UUID.randomUUID().toString()); + } + + private JwtClaimsSet.Builder claimsWithoutId(UUID userId) { + return JwtClaimsSet.builder() + .issuer(ISSUER) + .audience(List.of(AUDIENCE)) + .subject(userId.toString()) + .issuedAt(NOW) + .notBefore(NOW) + .expiresAt(NOW.plus(Duration.ofMinutes(15))) + .claim("token_use", "access") + .claim("auth_type", "LOGIN_JWT"); + } + + private String encode(JwtClaimsSet claims) { + JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).type("JWT").build(); + return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + } + + private void assertInvalidToken(String token, String statusCode) { + assertThatThrownBy(() -> loginJwtService.authenticate(token)) + .isInstanceOf(CredentialAuthenticationException.class) + .hasMessage(statusCode); + } + + private UserAccount activeUser() { + return UserAccount.create("Tester", "tester@example.com", null, NOW); + } + + private static final class InMemoryUserAccountRepository implements UserAccountRepository { + private final Map users = new HashMap<>(); + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(users.get(id)); + } + + @Override + public Optional findByIdForUpdate(UUID id) { + return findById(id); + } + + @Override + public Optional findByPrimaryEmailForUpdate(String primaryEmail) { + return users.values().stream() + .filter(user -> user.getPrimaryEmail().equalsIgnoreCase(primaryEmail)) + .findFirst(); + } + + @Override + public UserAccount save(UserAccount userAccount) { + users.put(userAccount.getId(), userAccount); + return userAccount; + } + } +} diff --git a/profanity-api/src/test/java/app/security/login/LoginAuthSupportConfigTest.java b/profanity-api/src/test/java/app/security/login/LoginAuthSupportConfigTest.java new file mode 100644 index 0000000..a3cb35c --- /dev/null +++ b/profanity-api/src/test/java/app/security/login/LoginAuthSupportConfigTest.java @@ -0,0 +1,60 @@ +package app.security.login; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class LoginAuthSupportConfigTest { + + @Test + @DisplayName("운영 환경의 refresh cookie가 Secure가 아니면 시작을 거부한다") + void validator_whenProductionCookieIsNotSecure_throwsIllegalStateException() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("prod"); + + assertThatThrownBy( + () -> + new LoginAuthSupportConfig.LoginSecurityConfigurationValidator( + properties(List.of("https://dashboard.example"), false), environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("LOGIN_REFRESH_COOKIE_SECURE"); + } + + @Test + @DisplayName("credentialed 로그인 CORS에 wildcard origin을 허용하지 않는다") + void validator_whenAllowedOriginIsWildcard_throwsIllegalStateException() { + assertThatThrownBy( + () -> + new LoginAuthSupportConfig.LoginSecurityConfigurationValidator( + properties(List.of("*"), true), new MockEnvironment())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("wildcard"); + } + + @Test + @DisplayName("로그인 CORS origin에 path가 포함되면 시작을 거부한다") + void validator_whenAllowedOriginContainsPath_throwsIllegalStateException() { + assertThatThrownBy( + () -> + new LoginAuthSupportConfig.LoginSecurityConfigurationValidator( + properties(List.of("https://dashboard.example/login"), true), + new MockEnvironment())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("scheme and host only"); + } + + private LoginSessionProperties properties(List origins, boolean secure) { + return new LoginSessionProperties( + Duration.ofMinutes(1), + Duration.ofDays(14), + Duration.ofDays(30), + Duration.ofSeconds(5), + origins, + new LoginSessionProperties.RefreshCookie( + "PF_LOGIN_REFRESH", secure, "Strict", "/api/v1/auth")); + } +} diff --git a/profanity-api/src/test/java/app/security/oauth2/GitHubOAuth2UserServiceTest.java b/profanity-api/src/test/java/app/security/oauth2/GitHubOAuth2UserServiceTest.java new file mode 100644 index 0000000..08066b3 --- /dev/null +++ b/profanity-api/src/test/java/app/security/oauth2/GitHubOAuth2UserServiceTest.java @@ -0,0 +1,153 @@ +package app.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +class GitHubOAuth2UserServiceTest { + private static final String ACCESS_TOKEN = "github-access-token"; + + @Test + @DisplayName("GitHub 기본 프로필과 검증된 대표 이메일을 하나의 사용자 속성으로 합친다") + void loadUser_whenVerifiedPrimaryEmailExists_enrichesProfile() throws Exception { + try (FakeGitHubServer server = + new FakeGitHubServer( + """ + [ + {"email":"secondary@example.com","primary":false,"verified":true}, + {"email":"primary@example.com","primary":true,"verified":true} + ] + """)) { + GitHubOAuth2UserService service = new GitHubOAuth2UserService(); + + OAuth2User user = service.loadUser(userRequest(server.baseUrl())); + + assertThat(user.getName()).isEqualTo("12345"); + assertThat(user.getAttribute("login")).isEqualTo("hgkim"); + assertThat(user.getAttribute("email")).isEqualTo("primary@example.com"); + assertThat(user.getAttribute("email_verified")).isTrue(); + assertThat(server.profileAuthorization()).isEqualTo("Bearer " + ACCESS_TOKEN); + assertThat(server.emailAuthorization()).isEqualTo("Bearer " + ACCESS_TOKEN); + } + } + + @Test + @DisplayName("GitHub에 검증된 대표 이메일이 없으면 로그인을 거부한다") + void loadUser_whenVerifiedPrimaryEmailIsMissing_throwsOAuth2AuthenticationException() + throws Exception { + try (FakeGitHubServer server = + new FakeGitHubServer( + """ + [ + {"email":"primary@example.com","primary":true,"verified":false}, + {"email":"secondary@example.com","primary":false,"verified":true} + ] + """)) { + GitHubOAuth2UserService service = new GitHubOAuth2UserService(); + + assertThatThrownBy(() -> service.loadUser(userRequest(server.baseUrl()))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + } + + private OAuth2UserRequest userRequest(String baseUrl) { + ClientRegistration registration = + ClientRegistration.withRegistrationId("github") + .clientId("client-id") + .clientSecret("client-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(baseUrl + "/login/oauth2/code/github") + .scope("read:user", "user:email") + .authorizationUri(baseUrl + "/oauth2/authorize") + .tokenUri(baseUrl + "/oauth2/token") + .userInfoUri(baseUrl + "/user") + .userNameAttributeName("id") + .clientName("GitHub") + .build(); + Instant issuedAt = Instant.now(); + OAuth2AccessToken accessToken = + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + ACCESS_TOKEN, + issuedAt, + issuedAt.plusSeconds(300), + Set.of("read:user", "user:email")); + return new OAuth2UserRequest(registration, accessToken); + } + + private static final class FakeGitHubServer implements AutoCloseable { + private final HttpServer server; + private final AtomicReference profileAuthorization = new AtomicReference<>(); + private final AtomicReference emailAuthorization = new AtomicReference<>(); + + private FakeGitHubServer(String emailResponse) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/user/emails", exchange -> respond(exchange, emailResponse, emailAuthorization)); + server.createContext( + "/user", + exchange -> + respond( + exchange, + """ + { + "id": 12345, + "login": "hgkim", + "name": "HG Kim", + "email": null, + "avatar_url": "https://example.com/avatar.png" + } + """, + profileAuthorization)); + server.start(); + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private String profileAuthorization() { + return profileAuthorization.get(); + } + + private String emailAuthorization() { + return emailAuthorization.get(); + } + + private void respond( + HttpExchange exchange, String responseBody, AtomicReference authorization) + throws IOException { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] body = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/profanity-api/src/test/java/app/security/oauth2/GoogleOidcUserServiceTest.java b/profanity-api/src/test/java/app/security/oauth2/GoogleOidcUserServiceTest.java new file mode 100644 index 0000000..fd8b59d --- /dev/null +++ b/profanity-api/src/test/java/app/security/oauth2/GoogleOidcUserServiceTest.java @@ -0,0 +1,136 @@ +package app.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +class GoogleOidcUserServiceTest { + + @Test + @DisplayName("Google의 검증된 Gmail 주소가 있으면 OIDC 사용자를 반환한다") + void loadUser_whenVerifiedEmailIsGmail_returnsOidcUser() { + OidcUser oidcUser = oidcUser("hgkim@gmail.com", true, null); + GoogleOidcUserService service = new GoogleOidcUserService(ignored -> oidcUser); + + OidcUser loaded = service.loadUser(userRequest(oidcUser.getIdToken())); + + assertThat(loaded).isSameAs(oidcUser); + assertThat(loaded.getEmail()).isEqualTo("hgkim@gmail.com"); + } + + @Test + @DisplayName("Google 이메일이 검증되지 않으면 로그인을 거부한다") + void loadUser_whenEmailIsUnverified_throwsOAuth2AuthenticationException() { + OidcUser oidcUser = oidcUser("hgkim@gmail.com", false, null); + GoogleOidcUserService service = new GoogleOidcUserService(ignored -> oidcUser); + + assertThatThrownBy(() -> service.loadUser(userRequest(oidcUser.getIdToken()))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + + @Test + @DisplayName("Google ID token에 hosted domain이 있으면 외부 이메일 로그인을 허용한다") + void loadUser_whenSignedHostedDomainExists_returnsOidcUser() { + OidcUser oidcUser = oidcUser("hgkim@company.example", true, "company.example"); + GoogleOidcUserService service = new GoogleOidcUserService(ignored -> oidcUser); + + OidcUser loaded = service.loadUser(userRequest(oidcUser.getIdToken())); + + assertThat(loaded).isSameAs(oidcUser); + } + + @Test + @DisplayName("Google 외부 이메일에 signed hosted domain이 없으면 로그인을 거부한다") + void loadUser_whenExternalEmailHasNoSignedHostedDomain_throwsOAuth2AuthenticationException() { + OidcUser oidcUser = oidcUser("hgkim@example.com", true, null); + GoogleOidcUserService service = new GoogleOidcUserService(ignored -> oidcUser); + + assertThatThrownBy(() -> service.loadUser(userRequest(oidcUser.getIdToken()))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + + @Test + @DisplayName("hosted domain이 UserInfo에만 있으면 signed claim으로 신뢰하지 않는다") + void loadUser_whenHostedDomainExistsOnlyInUserInfo_throwsOAuth2AuthenticationException() { + OidcUser idTokenUser = oidcUser("hgkim@example.com", true, null); + OidcUserInfo userInfo = + new OidcUserInfo( + Map.of( + "sub", "google-user-123", + "email", "hgkim@example.com", + "email_verified", true, + "hd", "company.example")); + OidcUser mergedUser = + new DefaultOidcUser( + List.of(new SimpleGrantedAuthority("OIDC_USER")), + idTokenUser.getIdToken(), + userInfo, + "sub"); + GoogleOidcUserService service = new GoogleOidcUserService(ignored -> mergedUser); + + assertThatThrownBy(() -> service.loadUser(userRequest(mergedUser.getIdToken()))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + + private OidcUser oidcUser(String email, boolean emailVerified, String hostedDomain) { + Instant issuedAt = Instant.now(); + Map claims = new LinkedHashMap<>(); + claims.put("sub", "google-user-123"); + claims.put("email", email); + claims.put("email_verified", emailVerified); + claims.put("name", "HG Kim"); + if (hostedDomain != null) { + claims.put("hd", hostedDomain); + } + OidcIdToken idToken = new OidcIdToken("id-token", issuedAt, issuedAt.plusSeconds(300), claims); + return new DefaultOidcUser(List.of(new SimpleGrantedAuthority("OIDC_USER")), idToken, "sub"); + } + + private OidcUserRequest userRequest(OidcIdToken idToken) { + ClientRegistration registration = + ClientRegistration.withRegistrationId("google") + .clientId("client-id") + .clientSecret("client-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("http://localhost/login/oauth2/code/google") + .scope("openid", "profile", "email") + .authorizationUri("https://accounts.example.test/oauth2/authorize") + .tokenUri("https://accounts.example.test/oauth2/token") + .jwkSetUri("https://accounts.example.test/oauth2/certs") + .userInfoUri("https://accounts.example.test/userinfo") + .userNameAttributeName("sub") + .clientName("Google") + .build(); + Instant issuedAt = Instant.now(); + OAuth2AccessToken accessToken = + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "google-access-token", + issuedAt, + issuedAt.plusSeconds(300), + Set.of("openid", "profile", "email")); + return new OidcUserRequest(registration, accessToken, idToken); + } +} diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java index d81ab69..3a1cc15 100644 --- a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginFailureHandlerTest.java @@ -16,7 +16,7 @@ class OAuth2LoginFailureHandlerTest { new SsoFrontendProperties("http://localhost:5173/login"); @Test - @DisplayName("OAuth2 로그인 실패 시 표준 status 코드와 상세 사유를 FE fragment로 redirect한다") + @DisplayName("OAuth2 로그인 실패 시 상세 예외를 노출하지 않고 표준 코드로 redirect한다") void onAuthenticationFailure_whenOAuth2LoginFailed_redirectsToFrontendFragment() throws Exception { OAuth2LoginFailureHandler failureHandler = new OAuth2LoginFailureHandler(FRONTEND_PROPERTIES); @@ -30,7 +30,6 @@ void onAuthenticationFailure_whenOAuth2LoginFailed_redirectsToFrontendFragment() .startsWith("http://localhost:5173/login#") .contains("error=oauth2_login_failed") .contains("statusCode=" + StatusCode.OAUTH2_LOGIN_FAILED.code()) - .contains("statusMessage=" + StatusCode.OAUTH2_LOGIN_FAILED.status()) - .contains("statusDetailDescription=state%20mismatch"); + .doesNotContain("statusDetailDescription", "state%20mismatch"); } } diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java index 0f15989..e20f7f7 100644 --- a/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2LoginSuccessHandlerTest.java @@ -1,35 +1,46 @@ package app.security.oauth2; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import app.application.auth.SsoLoginCompletionService; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; import jakarta.servlet.http.HttpServletResponse; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; class OAuth2LoginSuccessHandlerTest { - private static final SsoFrontendProperties FRONTEND_PROPERTIES = new SsoFrontendProperties("http://localhost:5173/login"); @Test - @DisplayName("GitHub 로그인 성공 시 mock dashboard token과 사용자 정보를 FE fragment로 redirect한다") - void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragment() + @DisplayName("GitHub 로그인 성공 시 일회용 코드만 FE fragment로 redirect한다") + void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsWithExchangeCodeOnly() throws Exception { - OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); OAuth2User oauth2User = new DefaultOAuth2User( List.of(new SimpleGrantedAuthority("ROLE_USER")), Map.of( "id", 12345, "login", "hgkim", + "email", "hgkim@example.com", + "email_verified", true, "avatar_url", "https://avatars.githubusercontent.com/u/12345"), "id"); OAuth2AuthenticationToken authentication = @@ -40,67 +51,161 @@ void onAuthenticationSuccess_whenGithubLoginSucceeded_redirectsToFrontendFragmen assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); assertThat(response.getRedirectedUrl()) - .startsWith("http://localhost:5173/login#") - .contains("provider=github") - .contains("providerUserId=12345") - .contains("providerLogin=hgkim") - .contains("githubUserId=12345") - .contains("githubLogin=hgkim") - .contains("dashboardAccessToken=mock_dashboard_token_"); + .isEqualTo("http://localhost:5173/login#code=one-time-code") + .doesNotContain("providerUserId", "providerEmail", "accessToken", "refreshToken"); + assertThat(response.getHeader("Cache-Control")).contains("no-store"); + assertThat(capturedProfile.get().provider()).isEqualTo(OAuthProvider.GITHUB); + assertThat(capturedProfile.get().providerUserId()).isEqualTo("12345"); + assertThat(capturedProfile.get().providerUsername()).isEqualTo("hgkim"); + assertThat(capturedProfile.get().providerEmail()).isEqualTo("hgkim@example.com"); + assertThat(capturedProfile.get().emailVerified()).isTrue(); + assertThat(capturedProfile.get().emailAuthoritative()).isTrue(); } @Test - @DisplayName("GitHub 사용자 속성이 비어 있어도 null 없이 FE fragment로 redirect한다") - void onAuthenticationSuccess_whenGithubAttributesAreMissing_redirectsWithEmptyStrings() + @DisplayName("GitHub 선택 속성이 비어 있으면 provider id를 표시 이름으로 사용한다") + void onAuthenticationSuccess_whenGithubOptionalAttributesAreMissing_usesProviderId() throws Exception { - OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); OAuth2User oauth2User = new DefaultOAuth2User( - List.of(new SimpleGrantedAuthority("ROLE_USER")), Map.of("id", 12345), "id"); + List.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of("id", 12345, "email", "hgkim@example.com", "email_verified", true), + "id"); OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "github"); - MockHttpServletResponse response = new MockHttpServletResponse(); - successHandler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication); + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); - assertThat(response.getRedirectedUrl()) - .contains("providerUserId=12345") - .contains("providerLogin=") - .contains("githubUserId=12345") - .contains("githubLogin=") - .contains("dashboardAccessToken=mock_dashboard_token_"); + assertThat(capturedProfile.get().displayName()).isEqualTo("12345"); + assertThat(capturedProfile.get().providerEmail()).isEqualTo("hgkim@example.com"); } @Test - @DisplayName("Google 로그인 성공 시 mock dashboard token과 사용자 정보를 FE fragment로 redirect한다") - void onAuthenticationSuccess_whenGoogleLoginSucceeded_redirectsToFrontendFragment() + @DisplayName("Google 외부 이메일에 signed hd가 없으면 로그인을 거부한다") + void onAuthenticationSuccess_whenGoogleEmailIsExternalWithoutHostedDomain_rejectsLogin() throws Exception { - OAuth2LoginSuccessHandler successHandler = new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES); + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); OAuth2User oauth2User = - new DefaultOAuth2User( - List.of(new SimpleGrantedAuthority("ROLE_USER")), + googleOidcUser( Map.of( "sub", "google-user-123", "email", "hgkim@example.com", + "email_verified", true, "name", "HG Kim", - "picture", "https://example.com/profile.png"), - "sub"); + "picture", "https://example.com/profile.png")); OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "google"); - MockHttpServletResponse response = new MockHttpServletResponse(); + assertThatThrownBy( + () -> + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + assertThat(capturedProfile.get()).isNull(); + } - successHandler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication); + @Test + @DisplayName("Google Gmail 주소는 authoritative 이메일로 전달한다") + void onAuthenticationSuccess_whenGoogleEmailIsGmail_marksEmailAuthoritative() throws Exception { + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); + OAuth2User oauth2User = + googleOidcUser( + Map.of( + "sub", "google-gmail-user", + "email", "HGKIM@GMAIL.COM", + "email_verified", true, + "name", "HG Kim")); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "google"); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); - assertThat(response.getRedirectedUrl()) - .startsWith("http://localhost:5173/login#") - .contains("provider=google") - .contains("providerUserId=google-user-123") - .contains("providerLogin=HG%20Kim") - .contains("providerEmail=hgkim%40example.com") - .contains("googleUserId=google-user-123") - .contains("googleEmail=hgkim%40example.com") - .contains("dashboardAccessToken=mock_dashboard_token_"); + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication); + + assertThat(capturedProfile.get().emailAuthoritative()).isTrue(); + } + + @Test + @DisplayName("Google signed hosted domain 계정은 authoritative 이메일로 전달한다") + void onAuthenticationSuccess_whenGoogleHostedDomainExists_marksEmailAuthoritative() + throws Exception { + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); + OAuth2User oauth2User = + googleOidcUser( + Map.of( + "sub", "google-hosted-user", + "email", "hgkim@company.example", + "email_verified", true, + "hd", "company.example", + "name", "HG Kim")); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "google"); + + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication); + + assertThat(capturedProfile.get().emailAuthoritative()).isTrue(); + } + + @Test + @DisplayName("GitHub 이메일이 검증되지 않으면 로그인 프로필을 전달하지 않는다") + void onAuthenticationSuccess_whenGithubEmailIsUnverified_rejectsLogin() { + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); + OAuth2User oauth2User = + new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of("id", 12345, "email", "hgkim@example.com", "email_verified", false), + "id"); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "github"); + + assertThatThrownBy( + () -> + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + assertThat(capturedProfile.get()).isNull(); + } + + @Test + @DisplayName("Google 이메일이 비어 있으면 로그인 프로필을 전달하지 않는다") + void onAuthenticationSuccess_whenGoogleEmailIsMissing_rejectsLogin() { + AtomicReference capturedProfile = new AtomicReference<>(); + OAuth2LoginSuccessHandler successHandler = handler(capturedProfile); + OAuth2User oauth2User = + googleOidcUser( + Map.of( + "sub", "google-user-123", + "email_verified", true, + "name", "HG Kim")); + OAuth2AuthenticationToken authentication = + new OAuth2AuthenticationToken(oauth2User, oauth2User.getAuthorities(), "google"); + + assertThatThrownBy( + () -> + successHandler.onAuthenticationSuccess( + new MockHttpServletRequest(), new MockHttpServletResponse(), authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + assertThat(capturedProfile.get()).isNull(); + } + + private OAuth2LoginSuccessHandler handler(AtomicReference capturedProfile) { + SsoLoginCompletionService loginCompletion = + profile -> { + capturedProfile.set(profile); + return "one-time-code"; + }; + return new OAuth2LoginSuccessHandler(FRONTEND_PROPERTIES, loginCompletion); + } + + private OAuth2User googleOidcUser(Map claims) { + Instant issuedAt = Instant.now(); + OidcIdToken idToken = new OidcIdToken("id-token", issuedAt, issuedAt.plusSeconds(300), claims); + return new DefaultOidcUser(List.of(new SimpleGrantedAuthority("OIDC_USER")), idToken, "sub"); } } diff --git a/profanity-api/src/test/java/app/security/oauth2/OAuth2SecuritySupportConfigTest.java b/profanity-api/src/test/java/app/security/oauth2/OAuth2SecuritySupportConfigTest.java new file mode 100644 index 0000000..9cb0cc8 --- /dev/null +++ b/profanity-api/src/test/java/app/security/oauth2/OAuth2SecuritySupportConfigTest.java @@ -0,0 +1,45 @@ +package app.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class OAuth2SecuritySupportConfigTest { + + @Test + @DisplayName("운영 환경에서 공개된 local signing key 기본값을 거부한다") + void validator_whenProductionUsesLocalStubSigningKey_throwsIllegalStateException() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("prod"); + SsoCookieProperties properties = + new SsoCookieProperties( + "PF_OAUTH2_AUTHORIZATION_REQUEST", + 300, + OAuth2SecuritySupportConfig.LOCAL_STUB_SIGNING_KEY); + + assertThatThrownBy( + () -> + new OAuth2SecuritySupportConfig.SsoCookieSecurityConfigurationValidator( + properties, environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SSO_COOKIE_SIGNING_KEY"); + } + + @Test + @DisplayName("운영 환경에서 별도 signing key를 설정하면 구성을 허용한다") + void validator_whenProductionUsesConfiguredSigningKey_acceptsConfiguration() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("prod"); + SsoCookieProperties properties = + new SsoCookieProperties("PF_OAUTH2_AUTHORIZATION_REQUEST", 300, "x".repeat(32)); + + assertThatCode( + () -> + new OAuth2SecuritySupportConfig.SsoCookieSecurityConfigurationValidator( + properties, environment)) + .doesNotThrowAnyException(); + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginAccountUnavailableException.java b/profanity-domain/src/main/java/app/application/auth/LoginAccountUnavailableException.java new file mode 100644 index 0000000..d2c650f --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginAccountUnavailableException.java @@ -0,0 +1,22 @@ +package app.application.auth; + +public class LoginAccountUnavailableException extends RuntimeException { + + private final Reason reason; + + public LoginAccountUnavailableException(Reason reason) { + super("Login account is unavailable: " + reason); + this.reason = reason; + } + + public Reason reason() { + return reason; + } + + public enum Reason { + USER_NOT_FOUND, + USER_INACTIVE, + VERIFIED_EMAIL_REQUIRED, + AUTHORITATIVE_EMAIL_REQUIRED + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeResult.java b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeResult.java new file mode 100644 index 0000000..1606543 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeResult.java @@ -0,0 +1,24 @@ +package app.application.auth; + +import java.util.UUID; + +public record LoginExchangeCodeConsumeResult( + LoginExchangeCodeConsumeStatus status, UUID codeId, UUID userId) { + + public static LoginExchangeCodeConsumeResult consumed(UUID codeId, UUID userId) { + return new LoginExchangeCodeConsumeResult( + LoginExchangeCodeConsumeStatus.CONSUMED, codeId, userId); + } + + public static LoginExchangeCodeConsumeResult failed( + LoginExchangeCodeConsumeStatus status, UUID codeId) { + if (status == LoginExchangeCodeConsumeStatus.CONSUMED) { + throw new IllegalArgumentException("Use consumed factory for a successful result"); + } + return new LoginExchangeCodeConsumeResult(status, codeId, null); + } + + public boolean isConsumed() { + return status == LoginExchangeCodeConsumeStatus.CONSUMED; + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeStatus.java b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeStatus.java new file mode 100644 index 0000000..5fcb942 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeConsumeStatus.java @@ -0,0 +1,9 @@ +package app.application.auth; + +public enum LoginExchangeCodeConsumeStatus { + CONSUMED, + INVALID, + EXPIRED, + ALREADY_CONSUMED, + USER_INACTIVE +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeIssue.java b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeIssue.java new file mode 100644 index 0000000..8d7b8e9 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeIssue.java @@ -0,0 +1,6 @@ +package app.application.auth; + +import java.time.Instant; +import java.util.UUID; + +public record LoginExchangeCodeIssue(UUID codeId, UUID userId, Instant expiresAt) {} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeService.java b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeService.java new file mode 100644 index 0000000..bf06426 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginExchangeCodeService.java @@ -0,0 +1,80 @@ +package app.application.auth; + +import app.domain.auth.LoginExchangeCode; +import app.domain.auth.LoginExchangeCodeRepository; +import app.domain.auth.Sha256Hash; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LoginExchangeCodeService { + + private final LoginExchangeCodeRepository exchangeCodeRepository; + private final UserAccountRepository userAccountRepository; + + @Transactional + public LoginExchangeCodeIssue issue( + UUID userId, Sha256Hash codeHash, Instant now, Duration timeToLive) { + requireActiveUser(userId); + LoginExchangeCode exchangeCode = + exchangeCodeRepository.save(LoginExchangeCode.issue(userId, codeHash, now, timeToLive)); + return new LoginExchangeCodeIssue( + exchangeCode.getId(), exchangeCode.getUserId(), exchangeCode.getExpiresAt()); + } + + @Transactional + public LoginExchangeCodeConsumeResult consume(Sha256Hash codeHash, Instant now) { + return exchangeCodeRepository + .findByCodeHashForUpdate(codeHash.value()) + .map(exchangeCode -> consumeLocked(exchangeCode, now)) + .orElseGet( + () -> + LoginExchangeCodeConsumeResult.failed( + LoginExchangeCodeConsumeStatus.INVALID, null)); + } + + private LoginExchangeCodeConsumeResult consumeLocked( + LoginExchangeCode exchangeCode, Instant now) { + if (exchangeCode.isConsumed()) { + return LoginExchangeCodeConsumeResult.failed( + LoginExchangeCodeConsumeStatus.ALREADY_CONSUMED, exchangeCode.getId()); + } + if (exchangeCode.isExpired(now)) { + return LoginExchangeCodeConsumeResult.failed( + LoginExchangeCodeConsumeStatus.EXPIRED, exchangeCode.getId()); + } + + UserAccount userAccount = findRequiredUser(exchangeCode.getUserId()); + exchangeCode.consume(now); + exchangeCodeRepository.save(exchangeCode); + if (!userAccount.isActive()) { + return LoginExchangeCodeConsumeResult.failed( + LoginExchangeCodeConsumeStatus.USER_INACTIVE, exchangeCode.getId()); + } + return LoginExchangeCodeConsumeResult.consumed(exchangeCode.getId(), exchangeCode.getUserId()); + } + + private void requireActiveUser(UUID userId) { + UserAccount userAccount = findRequiredUser(userId); + if (!userAccount.isActive()) { + throw new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.USER_INACTIVE); + } + } + + private UserAccount findRequiredUser(UUID userId) { + return userAccountRepository + .findByIdForUpdate(userId) + .orElseThrow( + () -> + new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.USER_NOT_FOUND)); + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationResult.java b/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationResult.java new file mode 100644 index 0000000..0138404 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationResult.java @@ -0,0 +1,34 @@ +package app.application.auth; + +import java.time.Instant; +import java.util.UUID; + +public record LoginRefreshRotationResult( + LoginRefreshRotationStatus status, + UUID sessionId, + UUID userId, + UUID replacementTokenId, + Instant replacementExpiresAt) { + + public static LoginRefreshRotationResult rotated( + UUID sessionId, UUID userId, UUID replacementTokenId, Instant replacementExpiresAt) { + return new LoginRefreshRotationResult( + LoginRefreshRotationStatus.ROTATED, + sessionId, + userId, + replacementTokenId, + replacementExpiresAt); + } + + public static LoginRefreshRotationResult failed( + LoginRefreshRotationStatus status, UUID sessionId) { + if (status == LoginRefreshRotationStatus.ROTATED) { + throw new IllegalArgumentException("Use rotated factory for a successful result"); + } + return new LoginRefreshRotationResult(status, sessionId, null, null, null); + } + + public boolean isRotated() { + return status == LoginRefreshRotationStatus.ROTATED; + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationStatus.java b/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationStatus.java new file mode 100644 index 0000000..7b63694 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginRefreshRotationStatus.java @@ -0,0 +1,12 @@ +package app.application.auth; + +public enum LoginRefreshRotationStatus { + ROTATED, + INVALID_TOKEN, + TOKEN_EXPIRED, + SESSION_EXPIRED, + SESSION_REVOKED, + REUSED_WITHIN_GRACE, + REUSE_DETECTED_SESSION_REVOKED, + USER_INACTIVE_SESSION_REVOKED +} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginRefreshSessionIssue.java b/profanity-domain/src/main/java/app/application/auth/LoginRefreshSessionIssue.java new file mode 100644 index 0000000..32d4fc8 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginRefreshSessionIssue.java @@ -0,0 +1,7 @@ +package app.application.auth; + +import java.time.Instant; +import java.util.UUID; + +public record LoginRefreshSessionIssue( + UUID sessionId, UUID tokenId, UUID userId, Instant tokenExpiresAt, Instant sessionExpiresAt) {} diff --git a/profanity-domain/src/main/java/app/application/auth/LoginRefreshTokenService.java b/profanity-domain/src/main/java/app/application/auth/LoginRefreshTokenService.java new file mode 100644 index 0000000..faf8395 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/LoginRefreshTokenService.java @@ -0,0 +1,179 @@ +package app.application.auth; + +import app.domain.auth.LoginRefreshSession; +import app.domain.auth.LoginRefreshSessionRepository; +import app.domain.auth.LoginRefreshToken; +import app.domain.auth.LoginRefreshTokenRepository; +import app.domain.auth.RefreshSessionRevocationReason; +import app.domain.auth.Sha256Hash; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LoginRefreshTokenService { + + private final LoginRefreshSessionRepository sessionRepository; + private final LoginRefreshTokenRepository tokenRepository; + private final UserAccountRepository userAccountRepository; + + @Transactional + public LoginRefreshSessionIssue createSession( + UUID userId, + Sha256Hash initialTokenHash, + Instant now, + Duration refreshTokenTimeToLive, + Duration absoluteSessionTimeToLive) { + requirePositive(refreshTokenTimeToLive, "refreshTokenTimeToLive"); + requirePositive(absoluteSessionTimeToLive, "absoluteSessionTimeToLive"); + requireActiveUser(userId); + + LoginRefreshSession session = + sessionRepository.save(LoginRefreshSession.create(userId, now, absoluteSessionTimeToLive)); + Instant tokenExpiresAt = + minimum(now.plus(refreshTokenTimeToLive), session.getAbsoluteExpiresAt()); + LoginRefreshToken token = + tokenRepository.save( + LoginRefreshToken.issue(session.getId(), initialTokenHash, now, tokenExpiresAt)); + return new LoginRefreshSessionIssue( + session.getId(), + token.getId(), + userId, + token.getExpiresAt(), + session.getAbsoluteExpiresAt()); + } + + @Transactional + public LoginRefreshRotationResult rotate( + Sha256Hash currentTokenHash, + Sha256Hash replacementTokenHash, + Instant now, + Duration refreshTokenTimeToLive, + Duration reuseGracePeriod) { + requirePositive(refreshTokenTimeToLive, "refreshTokenTimeToLive"); + requireNonNegative(reuseGracePeriod, "reuseGracePeriod"); + if (currentTokenHash.equals(replacementTokenHash)) { + throw new IllegalArgumentException("Replacement token hash must be different"); + } + + return tokenRepository + .findByTokenHashForUpdate(currentTokenHash.value()) + .map( + token -> + rotateLocked( + token, replacementTokenHash, now, refreshTokenTimeToLive, reuseGracePeriod)) + .orElseGet( + () -> + LoginRefreshRotationResult.failed(LoginRefreshRotationStatus.INVALID_TOKEN, null)); + } + + private LoginRefreshRotationResult rotateLocked( + LoginRefreshToken currentToken, + Sha256Hash replacementTokenHash, + Instant now, + Duration refreshTokenTimeToLive, + Duration reuseGracePeriod) { + LoginRefreshSession session = + sessionRepository + .findByIdForUpdate(currentToken.getSessionId()) + .orElseThrow( + () -> new IllegalStateException("Refresh token references a missing session")); + + if (session.isRevoked()) { + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.SESSION_REVOKED, session.getId()); + } + if (currentToken.isConsumed()) { + return handleTokenReuse(currentToken, session, now, reuseGracePeriod); + } + + UserAccount userAccount = findRequiredUser(session.getUserId()); + if (!userAccount.isActive()) { + session.revoke(now, RefreshSessionRevocationReason.USER_INACTIVE); + sessionRepository.save(session); + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.USER_INACTIVE_SESSION_REVOKED, session.getId()); + } + if (session.isExpired(now)) { + session.revoke(now, RefreshSessionRevocationReason.ABSOLUTE_EXPIRATION); + sessionRepository.save(session); + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.SESSION_EXPIRED, session.getId()); + } + if (currentToken.isExpired(now)) { + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.TOKEN_EXPIRED, session.getId()); + } + + Instant replacementExpiresAt = + minimum(now.plus(refreshTokenTimeToLive), session.getAbsoluteExpiresAt()); + LoginRefreshToken replacementToken = + LoginRefreshToken.issue(session.getId(), replacementTokenHash, now, replacementExpiresAt); + currentToken.consume(replacementToken.getId(), now); + session.markRotated(now); + tokenRepository.save(currentToken); + tokenRepository.save(replacementToken); + sessionRepository.save(session); + return LoginRefreshRotationResult.rotated( + session.getId(), + session.getUserId(), + replacementToken.getId(), + replacementToken.getExpiresAt()); + } + + private LoginRefreshRotationResult handleTokenReuse( + LoginRefreshToken token, + LoginRefreshSession session, + Instant now, + Duration reuseGracePeriod) { + if (token.wasReusedWithin(now, reuseGracePeriod)) { + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.REUSED_WITHIN_GRACE, session.getId()); + } + session.revoke(now, RefreshSessionRevocationReason.TOKEN_REUSE_DETECTED); + sessionRepository.save(session); + return LoginRefreshRotationResult.failed( + LoginRefreshRotationStatus.REUSE_DETECTED_SESSION_REVOKED, session.getId()); + } + + private void requireActiveUser(UUID userId) { + UserAccount userAccount = findRequiredUser(userId); + if (!userAccount.isActive()) { + throw new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.USER_INACTIVE); + } + } + + private UserAccount findRequiredUser(UUID userId) { + return userAccountRepository + .findByIdForUpdate(userId) + .orElseThrow( + () -> + new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.USER_NOT_FOUND)); + } + + private static Instant minimum(Instant first, Instant second) { + return first.isBefore(second) ? first : second; + } + + private static void requirePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } + + private static void requireNonNegative(Duration duration, String name) { + Objects.requireNonNull(duration, name + " must not be null"); + if (duration.isNegative()) { + throw new IllegalArgumentException(name + " must not be negative"); + } + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/SsoAccountService.java b/profanity-domain/src/main/java/app/application/auth/SsoAccountService.java new file mode 100644 index 0000000..ef177b7 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/SsoAccountService.java @@ -0,0 +1,29 @@ +package app.application.auth; + +import app.domain.user.OAuthLoginProfile; +import app.domain.user.UserAccount; +import java.time.Instant; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SsoAccountService { + + private final SsoAccountTransactionService transactionService; + + public UserAccount upsert(OAuthLoginProfile profile, Instant now) { + try { + return transactionService.upsertInNewTransaction(profile, now); + } catch (DataAccessException concurrentAccountConflict) { + Optional winner = + transactionService.synchronizeExistingInNewTransaction(profile, now); + if (winner.isPresent()) { + return winner.get(); + } + throw concurrentAccountConflict; + } + } +} diff --git a/profanity-domain/src/main/java/app/application/auth/SsoAccountTransactionService.java b/profanity-domain/src/main/java/app/application/auth/SsoAccountTransactionService.java new file mode 100644 index 0000000..e3c7751 --- /dev/null +++ b/profanity-domain/src/main/java/app/application/auth/SsoAccountTransactionService.java @@ -0,0 +1,102 @@ +package app.application.auth; + +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +import app.domain.user.OAuthAccount; +import app.domain.user.OAuthAccountRepository; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SsoAccountTransactionService { + + private final UserAccountRepository userAccountRepository; + private final OAuthAccountRepository oauthAccountRepository; + + @Transactional(propagation = REQUIRES_NEW) + public UserAccount upsertInNewTransaction(OAuthLoginProfile profile, Instant now) { + String primaryEmail = verifiedPrimaryEmail(profile); + requireAuthoritativeEmail(profile); + return synchronizeExisting(profile, primaryEmail, now) + .or(() -> linkExistingUser(profile, primaryEmail, now)) + .orElseGet(() -> createAccount(profile, primaryEmail, now)); + } + + @Transactional(propagation = REQUIRES_NEW) + public Optional synchronizeExistingInNewTransaction( + OAuthLoginProfile profile, Instant now) { + String primaryEmail = verifiedPrimaryEmail(profile); + requireAuthoritativeEmail(profile); + return synchronizeExisting(profile, primaryEmail, now) + .or(() -> linkExistingUser(profile, primaryEmail, now)); + } + + private Optional synchronizeExisting( + OAuthLoginProfile profile, String primaryEmail, Instant now) { + return oauthAccountRepository + .findByProviderAndProviderUserIdForUpdate(profile.provider(), profile.providerUserId()) + .map( + oauthAccount -> { + UserAccount userAccount = findRequiredUser(oauthAccount); + userAccount.synchronizeProfile( + profile.displayName(), primaryEmail, profile.avatarUrl(), now); + oauthAccount.synchronizeProfile(profile); + userAccountRepository.save(userAccount); + oauthAccountRepository.save(oauthAccount); + return userAccount; + }); + } + + private Optional linkExistingUser( + OAuthLoginProfile profile, String primaryEmail, Instant now) { + return userAccountRepository + .findByPrimaryEmailForUpdate(primaryEmail) + .map( + userAccount -> { + userAccount.synchronizeProfile( + profile.displayName(), primaryEmail, profile.avatarUrl(), now); + userAccountRepository.save(userAccount); + oauthAccountRepository.save(OAuthAccount.link(userAccount.getId(), profile, now)); + return userAccount; + }); + } + + private UserAccount createAccount(OAuthLoginProfile profile, String primaryEmail, Instant now) { + UserAccount userAccount = + UserAccount.create(profile.displayName(), primaryEmail, profile.avatarUrl(), now); + userAccountRepository.save(userAccount); + oauthAccountRepository.save(OAuthAccount.link(userAccount.getId(), profile, now)); + return userAccount; + } + + private UserAccount findRequiredUser(OAuthAccount oauthAccount) { + return userAccountRepository + .findByIdForUpdate(oauthAccount.getUserId()) + .orElseThrow(() -> new IllegalStateException("OAuth account references a missing user")); + } + + private static String verifiedPrimaryEmail(OAuthLoginProfile profile) { + if (!profile.emailVerified() + || profile.providerEmail() == null + || profile.providerEmail().isBlank()) { + throw new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.VERIFIED_EMAIL_REQUIRED); + } + return profile.providerEmail().trim().toLowerCase(Locale.ROOT); + } + + private static void requireAuthoritativeEmail(OAuthLoginProfile profile) { + if (!profile.emailAuthoritative()) { + throw new LoginAccountUnavailableException( + LoginAccountUnavailableException.Reason.AUTHORITATIVE_EMAIL_REQUIRED); + } + } +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCode.java b/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCode.java new file mode 100644 index 0000000..057ec38 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCode.java @@ -0,0 +1,85 @@ +package app.domain.auth; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString(of = {"id", "userId", "expiresAt", "consumedAt"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "login_exchange_codes") +@Table(name = "login_exchange_codes") +public class LoginExchangeCode { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID userId; + + @Column(name = "code_hash", nullable = false, unique = true, columnDefinition = "CHAR(64)") + private String codeHash; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_at") + private Instant consumedAt; + + public static LoginExchangeCode issue( + UUID userId, Sha256Hash codeHash, Instant now, Duration timeToLive) { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(codeHash, "codeHash must not be null"); + Instant requiredNow = Objects.requireNonNull(now, "now must not be null"); + requirePositive(timeToLive, "timeToLive"); + return LoginExchangeCode.builder() + .userId(userId) + .codeHash(codeHash.value()) + .createdAt(requiredNow) + .expiresAt(requiredNow.plus(timeToLive)) + .build(); + } + + public boolean isExpired(Instant now) { + return !Objects.requireNonNull(now, "now must not be null").isBefore(expiresAt); + } + + public boolean isConsumed() { + return consumedAt != null; + } + + public void consume(Instant now) { + if (isConsumed()) { + throw new IllegalStateException("Exchange code has already been consumed"); + } + this.consumedAt = Objects.requireNonNull(now, "now must not be null"); + } + + private static void requirePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCodeRepository.java b/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCodeRepository.java new file mode 100644 index 0000000..9c47e0d --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginExchangeCodeRepository.java @@ -0,0 +1,9 @@ +package app.domain.auth; + +import java.util.Optional; + +public interface LoginExchangeCodeRepository { + Optional findByCodeHashForUpdate(String codeHash); + + LoginExchangeCode save(LoginExchangeCode exchangeCode); +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSession.java b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSession.java new file mode 100644 index 0000000..1eb88bd --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSession.java @@ -0,0 +1,93 @@ +package app.domain.auth; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString(of = {"id", "userId", "absoluteExpiresAt", "revokedAt", "revokeReason"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "login_refresh_sessions") +@Table(name = "login_refresh_sessions") +public class LoginRefreshSession { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID userId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "absolute_expires_at", nullable = false) + private Instant absoluteExpiresAt; + + @Column(name = "last_rotated_at", nullable = false) + private Instant lastRotatedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "revoke_reason", length = 50) + private RefreshSessionRevocationReason revokeReason; + + public static LoginRefreshSession create(UUID userId, Instant now, Duration absoluteTimeToLive) { + Objects.requireNonNull(userId, "userId must not be null"); + Instant requiredNow = Objects.requireNonNull(now, "now must not be null"); + requirePositive(absoluteTimeToLive, "absoluteTimeToLive"); + return LoginRefreshSession.builder() + .userId(userId) + .createdAt(requiredNow) + .absoluteExpiresAt(requiredNow.plus(absoluteTimeToLive)) + .lastRotatedAt(requiredNow) + .build(); + } + + public boolean isExpired(Instant now) { + return !Objects.requireNonNull(now, "now must not be null").isBefore(absoluteExpiresAt); + } + + public boolean isRevoked() { + return revokedAt != null; + } + + public void markRotated(Instant now) { + this.lastRotatedAt = Objects.requireNonNull(now, "now must not be null"); + } + + public void revoke(Instant now, RefreshSessionRevocationReason reason) { + if (revokedAt == null) { + this.revokedAt = Objects.requireNonNull(now, "now must not be null"); + this.revokeReason = Objects.requireNonNull(reason, "reason must not be null"); + } + } + + private static void requirePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSessionRepository.java b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSessionRepository.java new file mode 100644 index 0000000..9c33472 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshSessionRepository.java @@ -0,0 +1,10 @@ +package app.domain.auth; + +import java.util.Optional; +import java.util.UUID; + +public interface LoginRefreshSessionRepository { + Optional findByIdForUpdate(UUID id); + + LoginRefreshSession save(LoginRefreshSession session); +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginRefreshToken.java b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshToken.java new file mode 100644 index 0000000..5dd21f4 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshToken.java @@ -0,0 +1,97 @@ +package app.domain.auth; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString(of = {"id", "sessionId", "issuedAt", "expiresAt", "consumedAt"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "login_refresh_tokens") +@Table(name = "login_refresh_tokens") +public class LoginRefreshToken { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "session_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID sessionId; + + @Column(name = "token_hash", nullable = false, unique = true, columnDefinition = "CHAR(64)") + private String tokenHash; + + @Column(name = "issued_at", nullable = false, updatable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_at") + private Instant consumedAt; + + @Column(name = "replaced_by_token_id", columnDefinition = "BINARY(16)") + private UUID replacedByTokenId; + + public static LoginRefreshToken issue( + UUID sessionId, Sha256Hash tokenHash, Instant now, Instant expiresAt) { + Objects.requireNonNull(sessionId, "sessionId must not be null"); + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + Instant requiredNow = Objects.requireNonNull(now, "now must not be null"); + Instant requiredExpiresAt = Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + if (!requiredExpiresAt.isAfter(requiredNow)) { + throw new IllegalArgumentException("expiresAt must be after now"); + } + return LoginRefreshToken.builder() + .sessionId(sessionId) + .tokenHash(tokenHash.value()) + .issuedAt(requiredNow) + .expiresAt(requiredExpiresAt) + .build(); + } + + public boolean isExpired(Instant now) { + return !Objects.requireNonNull(now, "now must not be null").isBefore(expiresAt); + } + + public boolean isConsumed() { + return consumedAt != null; + } + + public boolean wasReusedWithin(Instant now, java.time.Duration gracePeriod) { + Objects.requireNonNull(now, "now must not be null"); + if (!isConsumed()) { + return false; + } + if (gracePeriod == null || gracePeriod.isNegative()) { + throw new IllegalArgumentException("gracePeriod must not be negative"); + } + return !now.isAfter(consumedAt.plus(gracePeriod)); + } + + public void consume(UUID replacementTokenId, Instant now) { + if (isConsumed()) { + throw new IllegalStateException("Refresh token has already been consumed"); + } + this.replacedByTokenId = + Objects.requireNonNull(replacementTokenId, "replacementTokenId must not be null"); + this.consumedAt = Objects.requireNonNull(now, "now must not be null"); + } +} diff --git a/profanity-domain/src/main/java/app/domain/auth/LoginRefreshTokenRepository.java b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshTokenRepository.java new file mode 100644 index 0000000..2938b13 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/LoginRefreshTokenRepository.java @@ -0,0 +1,9 @@ +package app.domain.auth; + +import java.util.Optional; + +public interface LoginRefreshTokenRepository { + Optional findByTokenHashForUpdate(String tokenHash); + + LoginRefreshToken save(LoginRefreshToken token); +} diff --git a/profanity-domain/src/main/java/app/domain/auth/RefreshSessionRevocationReason.java b/profanity-domain/src/main/java/app/domain/auth/RefreshSessionRevocationReason.java new file mode 100644 index 0000000..861d8b5 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/RefreshSessionRevocationReason.java @@ -0,0 +1,7 @@ +package app.domain.auth; + +public enum RefreshSessionRevocationReason { + TOKEN_REUSE_DETECTED, + USER_INACTIVE, + ABSOLUTE_EXPIRATION +} diff --git a/profanity-domain/src/main/java/app/domain/auth/Sha256Hash.java b/profanity-domain/src/main/java/app/domain/auth/Sha256Hash.java new file mode 100644 index 0000000..ebcb800 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/auth/Sha256Hash.java @@ -0,0 +1,21 @@ +package app.domain.auth; + +import java.util.Locale; +import java.util.regex.Pattern; + +public record Sha256Hash(String value) { + + private static final Pattern SHA_256_HEX = Pattern.compile("[0-9a-fA-F]{64}"); + + public Sha256Hash { + if (value == null || !SHA_256_HEX.matcher(value).matches()) { + throw new IllegalArgumentException("A SHA-256 hash must be 64 hexadecimal characters"); + } + value = value.toLowerCase(Locale.ROOT); + } + + @Override + public String toString() { + return "Sha256Hash[value=[REDACTED]]"; + } +} diff --git a/profanity-domain/src/main/java/app/domain/client/Clients.java b/profanity-domain/src/main/java/app/domain/client/Clients.java index 20f4f37..6ce9e16 100644 --- a/profanity-domain/src/main/java/app/domain/client/Clients.java +++ b/profanity-domain/src/main/java/app/domain/client/Clients.java @@ -23,7 +23,7 @@ @Getter @Builder -@ToString(of = {"id", "name", "email", "apiKey"}) +@ToString(of = {"id", "name", "email"}) @EqualsAndHashCode(of = "id") @AllArgsConstructor(access = PRIVATE) @NoArgsConstructor(access = PROTECTED) diff --git a/profanity-domain/src/main/java/app/domain/user/OAuthAccount.java b/profanity-domain/src/main/java/app/domain/user/OAuthAccount.java new file mode 100644 index 0000000..1f8142c --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/OAuthAccount.java @@ -0,0 +1,87 @@ +package app.domain.user; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString(of = {"id", "userId", "provider"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "oauth_accounts") +@Table(name = "oauth_accounts") +public class OAuthAccount { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID userId; + + @Convert(converter = OAuthProviderConverter.class) + @Column(nullable = false, length = 30) + private OAuthProvider provider; + + @Column(name = "provider_user_id", nullable = false, length = 255) + private String providerUserId; + + @Column(name = "provider_email", length = 255) + private String providerEmail; + + @Column(name = "email_verified", nullable = false, columnDefinition = "TINYINT") + private boolean emailVerified; + + @Column(name = "provider_username", length = 100) + private String providerUsername; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Column(name = "linked_at", nullable = false, updatable = false) + private Instant linkedAt; + + public static OAuthAccount link(UUID userId, OAuthLoginProfile profile, Instant now) { + return OAuthAccount.builder() + .userId(userId) + .provider(profile.provider()) + .providerUserId(profile.providerUserId()) + .providerEmail(profile.providerEmail()) + .emailVerified(profile.emailVerified()) + .providerUsername(profile.providerUsername()) + .displayName(profile.displayName()) + .avatarUrl(profile.avatarUrl()) + .linkedAt(now) + .build(); + } + + public void synchronizeProfile(OAuthLoginProfile profile) { + if (provider != profile.provider() || !providerUserId.equals(profile.providerUserId())) { + throw new IllegalArgumentException("OAuth identity cannot be changed"); + } + this.providerEmail = profile.providerEmail(); + this.emailVerified = profile.emailVerified(); + this.providerUsername = profile.providerUsername(); + this.displayName = profile.displayName(); + this.avatarUrl = profile.avatarUrl(); + } +} diff --git a/profanity-domain/src/main/java/app/domain/user/OAuthAccountRepository.java b/profanity-domain/src/main/java/app/domain/user/OAuthAccountRepository.java new file mode 100644 index 0000000..03c3be2 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/OAuthAccountRepository.java @@ -0,0 +1,10 @@ +package app.domain.user; + +import java.util.Optional; + +public interface OAuthAccountRepository { + Optional findByProviderAndProviderUserIdForUpdate( + OAuthProvider provider, String providerUserId); + + OAuthAccount save(OAuthAccount oauthAccount); +} diff --git a/profanity-domain/src/main/java/app/domain/user/OAuthLoginProfile.java b/profanity-domain/src/main/java/app/domain/user/OAuthLoginProfile.java new file mode 100644 index 0000000..2af9adf --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/OAuthLoginProfile.java @@ -0,0 +1,39 @@ +package app.domain.user; + +import java.util.Objects; + +public record OAuthLoginProfile( + OAuthProvider provider, + String providerUserId, + String providerEmail, + boolean emailVerified, + boolean emailAuthoritative, + String providerUsername, + String displayName, + String avatarUrl) { + + public OAuthLoginProfile { + Objects.requireNonNull(provider, "provider must not be null"); + providerUserId = requireText(providerUserId, "providerUserId"); + displayName = requireText(displayName, "displayName"); + providerEmail = blankToNull(providerEmail); + providerUsername = blankToNull(providerUsername); + avatarUrl = blankToNull(avatarUrl); + } + + @Override + public String toString() { + return "OAuthLoginProfile[provider=" + provider + ", identity=[REDACTED]]"; + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.trim(); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/profanity-domain/src/main/java/app/domain/user/OAuthProvider.java b/profanity-domain/src/main/java/app/domain/user/OAuthProvider.java new file mode 100644 index 0000000..65233b6 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/OAuthProvider.java @@ -0,0 +1,28 @@ +package app.domain.user; + +import java.util.Arrays; + +public enum OAuthProvider { + GOOGLE("google"), + GITHUB("github"); + + private final String value; + + OAuthProvider(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public static OAuthProvider from(String value) { + if (value == null) { + throw new IllegalArgumentException("OAuth provider must not be null"); + } + return Arrays.stream(values()) + .filter(provider -> provider.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported OAuth provider")); + } +} diff --git a/profanity-domain/src/main/java/app/domain/user/OAuthProviderConverter.java b/profanity-domain/src/main/java/app/domain/user/OAuthProviderConverter.java new file mode 100644 index 0000000..b5e6b3c --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/OAuthProviderConverter.java @@ -0,0 +1,18 @@ +package app.domain.user; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class OAuthProviderConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(OAuthProvider attribute) { + return attribute == null ? null : attribute.value(); + } + + @Override + public OAuthProvider convertToEntityAttribute(String dbData) { + return dbData == null ? null : OAuthProvider.from(dbData); + } +} diff --git a/profanity-domain/src/main/java/app/domain/user/UserAccount.java b/profanity-domain/src/main/java/app/domain/user/UserAccount.java new file mode 100644 index 0000000..1a3c865 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/UserAccount.java @@ -0,0 +1,111 @@ +package app.domain.user; + +import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder(access = PRIVATE) +@ToString(of = {"id", "displayName", "status"}) +@EqualsAndHashCode(of = "id") +@AllArgsConstructor(access = PRIVATE) +@NoArgsConstructor(access = PROTECTED) +@Entity(name = "users") +@Table(name = "users") +public class UserAccount { + + @Id + @Builder.Default + @Column(columnDefinition = "BINARY(16)") + private UUID id = UUID.randomUUID(); + + @Column(name = "display_name", nullable = false, length = 100) + private String displayName; + + @Column(name = "primary_email", nullable = false, unique = true, length = 255) + private String primaryEmail; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Builder.Default + @Convert(converter = UserStatusConverter.class) + @Column(nullable = false, length = 30) + private UserStatus status = UserStatus.ACTIVE; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public static UserAccount create( + String displayName, String primaryEmail, String avatarUrl, Instant now) { + String requiredDisplayName = requireDisplayName(displayName); + Instant requiredNow = Objects.requireNonNull(now, "now must not be null"); + return UserAccount.builder() + .displayName(requiredDisplayName) + .primaryEmail(requirePrimaryEmail(primaryEmail)) + .avatarUrl(blankToNull(avatarUrl)) + .createdAt(requiredNow) + .updatedAt(requiredNow) + .build(); + } + + public void synchronizeProfile( + String displayName, String primaryEmail, String avatarUrl, Instant now) { + String requiredDisplayName = requireDisplayName(displayName); + String requiredPrimaryEmail = requirePrimaryEmail(primaryEmail); + String synchronizedAvatarUrl = blankToNull(avatarUrl); + Instant requiredNow = Objects.requireNonNull(now, "now must not be null"); + + this.displayName = requiredDisplayName; + this.primaryEmail = requiredPrimaryEmail; + if (synchronizedAvatarUrl != null) { + this.avatarUrl = synchronizedAvatarUrl; + } + this.updatedAt = requiredNow; + } + + public boolean isActive() { + return status == UserStatus.ACTIVE; + } + + public void disable(Instant now) { + this.status = UserStatus.DISABLED; + this.updatedAt = Objects.requireNonNull(now, "now must not be null"); + } + + private static String requireDisplayName(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("displayName must not be blank"); + } + return value.trim(); + } + + private static String requirePrimaryEmail(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("primaryEmail must not be blank"); + } + return value.trim().toLowerCase(Locale.ROOT); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/profanity-domain/src/main/java/app/domain/user/UserAccountRepository.java b/profanity-domain/src/main/java/app/domain/user/UserAccountRepository.java new file mode 100644 index 0000000..fe4e0dd --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/UserAccountRepository.java @@ -0,0 +1,14 @@ +package app.domain.user; + +import java.util.Optional; +import java.util.UUID; + +public interface UserAccountRepository { + Optional findById(UUID id); + + Optional findByIdForUpdate(UUID id); + + Optional findByPrimaryEmailForUpdate(String primaryEmail); + + UserAccount save(UserAccount userAccount); +} diff --git a/profanity-domain/src/main/java/app/domain/user/UserStatus.java b/profanity-domain/src/main/java/app/domain/user/UserStatus.java new file mode 100644 index 0000000..cbb1591 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/UserStatus.java @@ -0,0 +1,6 @@ +package app.domain.user; + +public enum UserStatus { + ACTIVE, + DISABLED +} diff --git a/profanity-domain/src/main/java/app/domain/user/UserStatusConverter.java b/profanity-domain/src/main/java/app/domain/user/UserStatusConverter.java new file mode 100644 index 0000000..92fd8f2 --- /dev/null +++ b/profanity-domain/src/main/java/app/domain/user/UserStatusConverter.java @@ -0,0 +1,17 @@ +package app.domain.user; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class UserStatusConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(UserStatus attribute) { + return attribute == null ? null : attribute.name(); + } + + @Override + public UserStatus convertToEntityAttribute(String dbData) { + return dbData == null ? null : UserStatus.valueOf(dbData); + } +} diff --git a/profanity-domain/src/test/java/app/application/auth/LoginExchangeCodeServiceTest.java b/profanity-domain/src/test/java/app/application/auth/LoginExchangeCodeServiceTest.java new file mode 100644 index 0000000..a627795 --- /dev/null +++ b/profanity-domain/src/test/java/app/application/auth/LoginExchangeCodeServiceTest.java @@ -0,0 +1,117 @@ +package app.application.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.domain.auth.LoginExchangeCode; +import app.domain.auth.LoginExchangeCodeRepository; +import app.domain.auth.Sha256Hash; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LoginExchangeCodeServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-11T00:00:00Z"); + private static final Sha256Hash CODE_HASH = new Sha256Hash("a".repeat(64)); + + private final InMemoryExchangeCodeRepository codeRepository = + new InMemoryExchangeCodeRepository(); + private final InMemoryUserAccountRepository userRepository = new InMemoryUserAccountRepository(); + private final LoginExchangeCodeService service = + new LoginExchangeCodeService(codeRepository, userRepository); + private UserAccount user; + + @BeforeEach + void setUp() { + user = UserAccount.create("Tester", "tester@example.com", null, NOW); + userRepository.save(user); + } + + @Test + @DisplayName("교환 코드는 유효 시간 안에 한 번만 소비할 수 있다") + void consume_whenCodeIsValid_consumesOnlyOnce() { + service.issue(user.getId(), CODE_HASH, NOW, Duration.ofSeconds(60)); + + LoginExchangeCodeConsumeResult first = service.consume(CODE_HASH, NOW.plusSeconds(10)); + LoginExchangeCodeConsumeResult second = service.consume(CODE_HASH, NOW.plusSeconds(11)); + + assertThat(first.status()).isEqualTo(LoginExchangeCodeConsumeStatus.CONSUMED); + assertThat(first.userId()).isEqualTo(user.getId()); + assertThat(second.status()).isEqualTo(LoginExchangeCodeConsumeStatus.ALREADY_CONSUMED); + } + + @Test + @DisplayName("만료 시각이 지난 교환 코드는 소비하지 않는다") + void consume_whenCodeExpired_returnsExpired() { + service.issue(user.getId(), CODE_HASH, NOW, Duration.ofSeconds(60)); + + LoginExchangeCodeConsumeResult result = service.consume(CODE_HASH, NOW.plusSeconds(60)); + + assertThat(result.status()).isEqualTo(LoginExchangeCodeConsumeStatus.EXPIRED); + assertThat(codeRepository.findByCodeHashForUpdate(CODE_HASH.value()).orElseThrow().isConsumed()) + .isFalse(); + } + + @Test + @DisplayName("비활성 사용자의 교환 코드는 실패하더라도 재사용할 수 없게 소비한다") + void consume_whenUserDisabled_consumesCodeAndRejectsLogin() { + service.issue(user.getId(), CODE_HASH, NOW, Duration.ofSeconds(60)); + user.disable(NOW.plusSeconds(1)); + + LoginExchangeCodeConsumeResult result = service.consume(CODE_HASH, NOW.plusSeconds(2)); + LoginExchangeCodeConsumeResult retried = service.consume(CODE_HASH, NOW.plusSeconds(3)); + + assertThat(result.status()).isEqualTo(LoginExchangeCodeConsumeStatus.USER_INACTIVE); + assertThat(retried.status()).isEqualTo(LoginExchangeCodeConsumeStatus.ALREADY_CONSUMED); + } + + private static final class InMemoryExchangeCodeRepository implements LoginExchangeCodeRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findByCodeHashForUpdate(String codeHash) { + return Optional.ofNullable(values.get(codeHash)); + } + + @Override + public LoginExchangeCode save(LoginExchangeCode exchangeCode) { + values.put(exchangeCode.getCodeHash(), exchangeCode); + return exchangeCode; + } + } + + private static final class InMemoryUserAccountRepository implements UserAccountRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(values.get(id)); + } + + @Override + public Optional findByIdForUpdate(UUID id) { + return findById(id); + } + + @Override + public Optional findByPrimaryEmailForUpdate(String primaryEmail) { + return values.values().stream() + .filter(user -> user.getPrimaryEmail().equalsIgnoreCase(primaryEmail.trim())) + .findFirst(); + } + + @Override + public UserAccount save(UserAccount userAccount) { + values.put(userAccount.getId(), userAccount); + return userAccount; + } + } +} diff --git a/profanity-domain/src/test/java/app/application/auth/LoginRefreshTokenServiceTest.java b/profanity-domain/src/test/java/app/application/auth/LoginRefreshTokenServiceTest.java new file mode 100644 index 0000000..4f8028e --- /dev/null +++ b/profanity-domain/src/test/java/app/application/auth/LoginRefreshTokenServiceTest.java @@ -0,0 +1,187 @@ +package app.application.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.domain.auth.LoginRefreshSession; +import app.domain.auth.LoginRefreshSessionRepository; +import app.domain.auth.LoginRefreshToken; +import app.domain.auth.LoginRefreshTokenRepository; +import app.domain.auth.RefreshSessionRevocationReason; +import app.domain.auth.Sha256Hash; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LoginRefreshTokenServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-11T00:00:00Z"); + private static final Duration REFRESH_TTL = Duration.ofDays(14); + private static final Duration ABSOLUTE_TTL = Duration.ofDays(30); + private static final Duration GRACE = Duration.ofSeconds(3); + private static final Sha256Hash INITIAL_HASH = hash('a'); + private static final Sha256Hash REPLACEMENT_HASH = hash('b'); + + private final InMemoryUserAccountRepository userRepository = new InMemoryUserAccountRepository(); + private final InMemoryRefreshSessionRepository sessionRepository = + new InMemoryRefreshSessionRepository(); + private final InMemoryRefreshTokenRepository tokenRepository = + new InMemoryRefreshTokenRepository(); + private final LoginRefreshTokenService service = + new LoginRefreshTokenService(sessionRepository, tokenRepository, userRepository); + private UserAccount user; + + @BeforeEach + void setUp() { + user = UserAccount.create("Tester", "tester@example.com", null, NOW); + userRepository.save(user); + } + + @Test + @DisplayName("refresh token rotation은 기존 토큰을 소비하고 교체 토큰을 발급한다") + void rotate_whenCurrentTokenIsValid_rotatesToken() { + LoginRefreshSessionIssue issue = createSession(); + + LoginRefreshRotationResult result = + service.rotate(INITIAL_HASH, REPLACEMENT_HASH, NOW.plusSeconds(1), REFRESH_TTL, GRACE); + + LoginRefreshToken initial = tokenRepository.find(INITIAL_HASH).orElseThrow(); + assertThat(result.status()).isEqualTo(LoginRefreshRotationStatus.ROTATED); + assertThat(result.sessionId()).isEqualTo(issue.sessionId()); + assertThat(initial.isConsumed()).isTrue(); + assertThat(initial.getReplacedByTokenId()).isEqualTo(result.replacementTokenId()); + assertThat(tokenRepository.find(REPLACEMENT_HASH)).isPresent(); + } + + @Test + @DisplayName("소비된 토큰이 grace 안에 재사용되면 family를 유지한다") + void rotate_whenConsumedTokenReusedWithinGrace_keepsSession() { + LoginRefreshSessionIssue issue = createSession(); + Instant rotatedAt = NOW.plusSeconds(1); + service.rotate(INITIAL_HASH, REPLACEMENT_HASH, rotatedAt, REFRESH_TTL, GRACE); + + LoginRefreshRotationResult result = + service.rotate(INITIAL_HASH, hash('c'), rotatedAt.plus(GRACE), REFRESH_TTL, GRACE); + + assertThat(result.status()).isEqualTo(LoginRefreshRotationStatus.REUSED_WITHIN_GRACE); + assertThat(sessionRepository.find(issue.sessionId()).orElseThrow().isRevoked()).isFalse(); + } + + @Test + @DisplayName("소비된 토큰이 grace 이후 재사용되면 token family 전체를 폐기한다") + void rotate_whenConsumedTokenReusedAfterGrace_revokesSession() { + LoginRefreshSessionIssue issue = createSession(); + Instant rotatedAt = NOW.plusSeconds(1); + service.rotate(INITIAL_HASH, REPLACEMENT_HASH, rotatedAt, REFRESH_TTL, GRACE); + + LoginRefreshRotationResult replay = + service.rotate( + INITIAL_HASH, hash('c'), rotatedAt.plus(GRACE).plusNanos(1), REFRESH_TTL, GRACE); + LoginRefreshRotationResult winnerToken = + service.rotate( + REPLACEMENT_HASH, hash('d'), rotatedAt.plus(GRACE).plusSeconds(1), REFRESH_TTL, GRACE); + + LoginRefreshSession session = sessionRepository.find(issue.sessionId()).orElseThrow(); + assertThat(replay.status()) + .isEqualTo(LoginRefreshRotationStatus.REUSE_DETECTED_SESSION_REVOKED); + assertThat(session.isRevoked()).isTrue(); + assertThat(session.getRevokeReason()) + .isEqualTo(RefreshSessionRevocationReason.TOKEN_REUSE_DETECTED); + assertThat(winnerToken.status()).isEqualTo(LoginRefreshRotationStatus.SESSION_REVOKED); + } + + @Test + @DisplayName("사용자가 비활성화되면 refresh session을 폐기한다") + void rotate_whenUserDisabled_revokesSession() { + LoginRefreshSessionIssue issue = createSession(); + user.disable(NOW.plusSeconds(1)); + + LoginRefreshRotationResult result = + service.rotate(INITIAL_HASH, REPLACEMENT_HASH, NOW.plusSeconds(2), REFRESH_TTL, GRACE); + + LoginRefreshSession session = sessionRepository.find(issue.sessionId()).orElseThrow(); + assertThat(result.status()).isEqualTo(LoginRefreshRotationStatus.USER_INACTIVE_SESSION_REVOKED); + assertThat(session.getRevokeReason()).isEqualTo(RefreshSessionRevocationReason.USER_INACTIVE); + } + + private LoginRefreshSessionIssue createSession() { + return service.createSession(user.getId(), INITIAL_HASH, NOW, REFRESH_TTL, ABSOLUTE_TTL); + } + + private static Sha256Hash hash(char value) { + return new Sha256Hash(String.valueOf(value).repeat(64)); + } + + private static final class InMemoryUserAccountRepository implements UserAccountRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(values.get(id)); + } + + @Override + public Optional findByIdForUpdate(UUID id) { + return findById(id); + } + + @Override + public Optional findByPrimaryEmailForUpdate(String primaryEmail) { + return values.values().stream() + .filter(user -> user.getPrimaryEmail().equalsIgnoreCase(primaryEmail.trim())) + .findFirst(); + } + + @Override + public UserAccount save(UserAccount userAccount) { + values.put(userAccount.getId(), userAccount); + return userAccount; + } + } + + private static final class InMemoryRefreshSessionRepository + implements LoginRefreshSessionRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findByIdForUpdate(UUID id) { + return Optional.ofNullable(values.get(id)); + } + + @Override + public LoginRefreshSession save(LoginRefreshSession session) { + values.put(session.getId(), session); + return session; + } + + Optional find(UUID id) { + return Optional.ofNullable(values.get(id)); + } + } + + private static final class InMemoryRefreshTokenRepository implements LoginRefreshTokenRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findByTokenHashForUpdate(String tokenHash) { + return Optional.ofNullable(values.get(tokenHash)); + } + + @Override + public LoginRefreshToken save(LoginRefreshToken token) { + values.put(token.getTokenHash(), token); + return token; + } + + Optional find(Sha256Hash hash) { + return Optional.ofNullable(values.get(hash.value())); + } + } +} diff --git a/profanity-domain/src/test/java/app/application/auth/SsoAccountServiceTest.java b/profanity-domain/src/test/java/app/application/auth/SsoAccountServiceTest.java new file mode 100644 index 0000000..35736f3 --- /dev/null +++ b/profanity-domain/src/test/java/app/application/auth/SsoAccountServiceTest.java @@ -0,0 +1,162 @@ +package app.application.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.domain.user.OAuthAccount; +import app.domain.user.OAuthAccountRepository; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +class SsoAccountServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-11T00:00:00Z"); + + @Test + @DisplayName("동일 이메일 사용자의 동시 생성 충돌 시 한 번 재시도해 OAuth 계정을 연결한다") + void upsert_whenSameEmailCreateConflicts_retriesAndLinksWinner() { + ConflictOnceUserAccountRepository userRepository = new ConflictOnceUserAccountRepository(); + InMemoryOAuthAccountRepository oauthRepository = new InMemoryOAuthAccountRepository(); + SsoAccountTransactionService transactionService = + new SsoAccountTransactionService(userRepository, oauthRepository); + SsoAccountService service = new SsoAccountService(transactionService); + OAuthLoginProfile profile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "concurrent-provider-user", + " Race@Example.COM ", + true, + true, + "race-user", + "Race User", + null); + + UserAccount result = service.upsert(profile, NOW); + + assertThat(result.getId()).isEqualTo(userRepository.winner().getId()); + assertThat(result.getPrimaryEmail()).isEqualTo("race@example.com"); + assertThat(userRepository.size()).isEqualTo(1); + assertThat(userRepository.emailLookupCount()).isEqualTo(2); + assertThat(oauthRepository.onlyAccount().getUserId()).isEqualTo(result.getId()); + } + + @Test + @DisplayName("권위 있는 이메일이 아니면 race 처리 전에 SSO 계정 upsert를 거부한다") + void upsert_whenEmailIsNotAuthoritative_rejectsBeforeRaceHandling() { + ConflictOnceUserAccountRepository userRepository = new ConflictOnceUserAccountRepository(); + InMemoryOAuthAccountRepository oauthRepository = new InMemoryOAuthAccountRepository(); + SsoAccountTransactionService transactionService = + new SsoAccountTransactionService(userRepository, oauthRepository); + SsoAccountService service = new SsoAccountService(transactionService); + OAuthLoginProfile profile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "untrusted-concurrent-user", + "race@example.com", + true, + false, + "untrusted-race", + "Untrusted Race", + null); + + assertThatThrownBy(() -> service.upsert(profile, NOW)) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo( + LoginAccountUnavailableException.Reason.AUTHORITATIVE_EMAIL_REQUIRED)); + assertThat(userRepository.size()).isZero(); + assertThat(userRepository.emailLookupCount()).isZero(); + assertThat(oauthRepository.size()).isZero(); + } + + private static final class ConflictOnceUserAccountRepository implements UserAccountRepository { + private final Map values = new LinkedHashMap<>(); + private boolean conflictPending = true; + private int emailLookupCount; + private UserAccount winner; + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(values.get(id)); + } + + @Override + public Optional findByIdForUpdate(UUID id) { + return findById(id); + } + + @Override + public Optional findByPrimaryEmailForUpdate(String primaryEmail) { + emailLookupCount++; + return values.values().stream() + .filter(user -> user.getPrimaryEmail().equalsIgnoreCase(primaryEmail.trim())) + .findFirst(); + } + + @Override + public UserAccount save(UserAccount userAccount) { + if (conflictPending) { + conflictPending = false; + winner = + UserAccount.create( + "Concurrent Winner", userAccount.getPrimaryEmail(), null, NOW.minusSeconds(1)); + values.put(winner.getId(), winner); + throw new DataIntegrityViolationException("simulated unique email conflict"); + } + values.put(userAccount.getId(), userAccount); + return userAccount; + } + + UserAccount winner() { + return winner; + } + + int size() { + return values.size(); + } + + int emailLookupCount() { + return emailLookupCount; + } + } + + private static final class InMemoryOAuthAccountRepository implements OAuthAccountRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findByProviderAndProviderUserIdForUpdate( + OAuthProvider provider, String providerUserId) { + return Optional.ofNullable(values.get(new ProviderIdentity(provider, providerUserId))); + } + + @Override + public OAuthAccount save(OAuthAccount oauthAccount) { + values.put( + new ProviderIdentity(oauthAccount.getProvider(), oauthAccount.getProviderUserId()), + oauthAccount); + return oauthAccount; + } + + OAuthAccount onlyAccount() { + return values.values().iterator().next(); + } + + int size() { + return values.size(); + } + } + + private record ProviderIdentity(OAuthProvider provider, String providerUserId) {} +} diff --git a/profanity-domain/src/test/java/app/application/auth/SsoAccountTransactionServiceTest.java b/profanity-domain/src/test/java/app/application/auth/SsoAccountTransactionServiceTest.java new file mode 100644 index 0000000..2a4edec --- /dev/null +++ b/profanity-domain/src/test/java/app/application/auth/SsoAccountTransactionServiceTest.java @@ -0,0 +1,275 @@ +package app.application.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.domain.user.OAuthAccount; +import app.domain.user.OAuthAccountRepository; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SsoAccountTransactionServiceTest { + + private static final Instant FIRST_LOGIN_AT = Instant.parse("2026-07-11T00:00:00Z"); + + private final InMemoryUserAccountRepository userRepository = new InMemoryUserAccountRepository(); + private final InMemoryOAuthAccountRepository oauthRepository = + new InMemoryOAuthAccountRepository(); + private final SsoAccountTransactionService service = + new SsoAccountTransactionService(userRepository, oauthRepository); + + @Test + @DisplayName("동일한 provider 사용자가 다시 로그인하면 기존 내부 사용자를 갱신한다") + void upsertInNewTransaction_whenSameProviderUser_updatesExistingUser() { + OAuthLoginProfile firstProfile = + profile(OAuthProvider.GITHUB, "provider-user-1", "first@example.com", "First"); + UserAccount first = service.upsertInNewTransaction(firstProfile, FIRST_LOGIN_AT); + + OAuthLoginProfile changedProfile = + profile(OAuthProvider.GITHUB, "provider-user-1", "changed@example.com", "Changed"); + UserAccount second = + service.upsertInNewTransaction(changedProfile, FIRST_LOGIN_AT.plusSeconds(30)); + + assertThat(second.getId()).isEqualTo(first.getId()); + assertThat(second.getDisplayName()).isEqualTo("Changed"); + assertThat(second.getPrimaryEmail()).isEqualTo("changed@example.com"); + assertThat(userRepository.size()).isEqualTo(1); + assertThat(oauthRepository.size()).isEqualTo(1); + } + + @Test + @DisplayName("검증된 이메일이 같으면 대소문자와 공백을 정규해 기존 사용자에 OAuth 계정을 연결한다") + void upsertInNewTransaction_whenVerifiedEmailMatches_linksExistingUser() { + UserAccount githubUser = + service.upsertInNewTransaction( + profile(OAuthProvider.GITHUB, "github-user", " Same@Example.COM ", "GitHub User"), + FIRST_LOGIN_AT); + UserAccount googleUser = + service.upsertInNewTransaction( + profile(OAuthProvider.GOOGLE, "google-user", "same@example.com", "Google User"), + FIRST_LOGIN_AT.plusSeconds(1)); + + assertThat(googleUser.getId()).isEqualTo(githubUser.getId()); + assertThat(googleUser.getPrimaryEmail()).isEqualTo("same@example.com"); + assertThat(userRepository.size()).isEqualTo(1); + assertThat(oauthRepository.size()).isEqualTo(2); + } + + @Test + @DisplayName("provider가 검증하지 않은 이메일이면 SSO 계정 생성을 거부한다") + void upsertInNewTransaction_whenEmailIsUnverified_rejectsAccount() { + OAuthLoginProfile unverifiedProfile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "unverified-user", + "unverified@example.com", + false, + true, + "unverified", + "Unverified User", + null); + + assertThatThrownBy(() -> service.upsertInNewTransaction(unverifiedProfile, FIRST_LOGIN_AT)) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(LoginAccountUnavailableException.Reason.VERIFIED_EMAIL_REQUIRED)); + assertThat(userRepository.size()).isZero(); + assertThat(oauthRepository.size()).isZero(); + } + + @Test + @DisplayName("미검증 이메일 재로그인은 거부하고 기존 사용자를 변경하지 않는다") + void upsertInNewTransaction_whenReloginEmailIsUnverified_rejectsWithoutChanges() { + OAuthLoginProfile verifiedProfile = + profile(OAuthProvider.GITHUB, "provider-user-2", "verified@example.com", "Verified"); + UserAccount first = service.upsertInNewTransaction(verifiedProfile, FIRST_LOGIN_AT); + OAuthLoginProfile unverifiedProfile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "provider-user-2", + "unverified@example.com", + false, + true, + "unverified", + "Unverified", + null); + + assertThatThrownBy( + () -> service.upsertInNewTransaction(unverifiedProfile, FIRST_LOGIN_AT.plusSeconds(30))) + .isInstanceOf(LoginAccountUnavailableException.class); + assertThat(first.getPrimaryEmail()).isEqualTo("verified@example.com"); + } + + @Test + @DisplayName("기존 provider identity도 권위 있는 이메일이 아니면 upsert를 거부한다") + void upsertInNewTransaction_whenIdentityExistsButEmailIsNotAuthoritative_rejectsUpsert() { + OAuthLoginProfile trustedProfile = + profile(OAuthProvider.GITHUB, "existing-identity", "existing@example.com", "Existing"); + UserAccount first = service.upsertInNewTransaction(trustedProfile, FIRST_LOGIN_AT); + OAuthLoginProfile untrustedProfile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "existing-identity", + "existing@example.com", + true, + false, + "existing", + "Existing Updated", + null); + + assertThatThrownBy( + () -> service.upsertInNewTransaction(untrustedProfile, FIRST_LOGIN_AT.plusSeconds(30))) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo( + LoginAccountUnavailableException.Reason.AUTHORITATIVE_EMAIL_REQUIRED)); + assertThat(first.getDisplayName()).isEqualTo("Existing"); + assertThat(userRepository.size()).isEqualTo(1); + assertThat(oauthRepository.size()).isEqualTo(1); + } + + @Test + @DisplayName("신뢰할 수 없는 이메일은 기존 사용자에 새 OAuth 계정으로 연결하지 않는다") + void upsertInNewTransaction_whenEmailMatchesButIsUntrusted_rejectsLink() { + UserAccount existing = + service.upsertInNewTransaction( + profile(OAuthProvider.GITHUB, "trusted-user", "shared@example.com", "Trusted"), + FIRST_LOGIN_AT); + OAuthLoginProfile untrustedProfile = + new OAuthLoginProfile( + OAuthProvider.GOOGLE, + "untrusted-user", + " SHARED@EXAMPLE.COM ", + true, + false, + "untrusted", + "Untrusted", + null); + + assertThatThrownBy( + () -> service.upsertInNewTransaction(untrustedProfile, FIRST_LOGIN_AT.plusSeconds(1))) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo( + LoginAccountUnavailableException.Reason.AUTHORITATIVE_EMAIL_REQUIRED)); + assertThat(existing.getDisplayName()).isEqualTo("Trusted"); + assertThat(userRepository.size()).isEqualTo(1); + assertThat(oauthRepository.size()).isEqualTo(1); + } + + @Test + @DisplayName("동일 사용자가 없어도 권위 있는 이메일이 아니면 신규 계정을 생성하지 않는다") + void upsertInNewTransaction_whenEmailIsNotAuthoritativeAndUnused_rejectsCreation() { + OAuthLoginProfile untrustedProfile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "new-untrusted-user", + "NEW@EXAMPLE.COM", + true, + false, + "new-untrusted", + "New Untrusted", + null); + + assertThatThrownBy(() -> service.upsertInNewTransaction(untrustedProfile, FIRST_LOGIN_AT)) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo( + LoginAccountUnavailableException.Reason.AUTHORITATIVE_EMAIL_REQUIRED)); + assertThat(userRepository.size()).isZero(); + assertThat(oauthRepository.size()).isZero(); + } + + @Test + @DisplayName("provider에 검증된 이메일 값이 없으면 SSO 계정 생성을 거부한다") + void upsertInNewTransaction_whenVerifiedEmailIsMissing_rejectsAccount() { + OAuthLoginProfile missingEmailProfile = + new OAuthLoginProfile( + OAuthProvider.GOOGLE, "missing-email", null, true, true, null, "Missing Email", null); + + assertThatThrownBy(() -> service.upsertInNewTransaction(missingEmailProfile, FIRST_LOGIN_AT)) + .isInstanceOfSatisfying( + LoginAccountUnavailableException.class, + exception -> + assertThat(exception.reason()) + .isEqualTo(LoginAccountUnavailableException.Reason.VERIFIED_EMAIL_REQUIRED)); + } + + private static OAuthLoginProfile profile( + OAuthProvider provider, String providerUserId, String email, String displayName) { + return new OAuthLoginProfile( + provider, providerUserId, email, true, true, displayName, displayName, null); + } + + private static final class InMemoryUserAccountRepository implements UserAccountRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(values.get(id)); + } + + @Override + public Optional findByIdForUpdate(UUID id) { + return findById(id); + } + + @Override + public Optional findByPrimaryEmailForUpdate(String primaryEmail) { + return values.values().stream() + .filter(user -> user.getPrimaryEmail().equalsIgnoreCase(primaryEmail.trim())) + .findFirst(); + } + + @Override + public UserAccount save(UserAccount userAccount) { + values.put(userAccount.getId(), userAccount); + return userAccount; + } + + int size() { + return values.size(); + } + } + + private static final class InMemoryOAuthAccountRepository implements OAuthAccountRepository { + private final Map values = new LinkedHashMap<>(); + + @Override + public Optional findByProviderAndProviderUserIdForUpdate( + OAuthProvider provider, String providerUserId) { + return Optional.ofNullable(values.get(new ProviderIdentity(provider, providerUserId))); + } + + @Override + public OAuthAccount save(OAuthAccount oauthAccount) { + values.put( + new ProviderIdentity(oauthAccount.getProvider(), oauthAccount.getProviderUserId()), + oauthAccount); + return oauthAccount; + } + + int size() { + return values.size(); + } + } + + private record ProviderIdentity(OAuthProvider provider, String providerUserId) {} +} diff --git a/profanity-domain/src/test/java/app/domain/user/UserAccountTest.java b/profanity-domain/src/test/java/app/domain/user/UserAccountTest.java new file mode 100644 index 0000000..faa1b09 --- /dev/null +++ b/profanity-domain/src/test/java/app/domain/user/UserAccountTest.java @@ -0,0 +1,41 @@ +package app.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UserAccountTest { + + private static final Instant NOW = Instant.parse("2026-07-11T00:00:00Z"); + + @Test + @DisplayName("사용자 생성 시 대표 이메일을 trim하고 소문자로 정규화한다") + void create_whenPrimaryEmailHasMixedCaseAndSpaces_normalizesEmail() { + UserAccount user = UserAccount.create("Tester", " Tester@Example.COM ", null, NOW); + + assertThat(user.getPrimaryEmail()).isEqualTo("tester@example.com"); + } + + @Test + @DisplayName("사용자 생성 시 대표 이메일이 비어 있으면 거부한다") + void create_whenPrimaryEmailIsBlank_throwsException() { + assertThatThrownBy(() -> UserAccount.create("Tester", " ", null, NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("primaryEmail must not be blank"); + } + + @Test + @DisplayName("프로필 동기화 시 대표 이메일이 null이면 거부한다") + void synchronizeProfile_whenPrimaryEmailIsNull_throwsException() { + UserAccount user = UserAccount.create("Tester", "tester@example.com", null, NOW); + + assertThatThrownBy(() -> user.synchronizeProfile("Changed", null, null, NOW.plusSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("primaryEmail must not be blank"); + assertThat(user.getDisplayName()).isEqualTo("Tester"); + assertThat(user.getPrimaryEmail()).isEqualTo("tester@example.com"); + } +} diff --git a/profanity-shared/src/main/java/app/core/data/response/ApiResponse.java b/profanity-shared/src/main/java/app/core/data/response/ApiResponse.java index 1fa229f..c30ffa9 100644 --- a/profanity-shared/src/main/java/app/core/data/response/ApiResponse.java +++ b/profanity-shared/src/main/java/app/core/data/response/ApiResponse.java @@ -6,6 +6,7 @@ import java.util.HashMap; import java.util.Map; import lombok.Builder; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; @Schema(description = "공통 API 응답 래퍼") @@ -27,4 +28,8 @@ public static ResponseEntity> ok(T data) { public static ResponseEntity> error(Status status) { return ResponseEntity.ok(ApiResponse.of(status, null)); } + + public static ResponseEntity> error(HttpStatusCode httpStatus, Status status) { + return ResponseEntity.status(httpStatus).body(ApiResponse.of(status, null)); + } } diff --git a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java index 45708eb..84c352e 100644 --- a/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java +++ b/profanity-shared/src/main/java/app/core/data/response/constant/StatusCode.java @@ -12,12 +12,20 @@ public enum StatusCode { NOT_FOUNT_TRACKING_ID(4003, "유효하지 않은 Tracking ID 입니다. Tracking ID를 확인해 주세요."), UNAUTHORIZED(4010, "인증 키가 누락 되었습니다."), OAUTH2_LOGIN_FAILED(4011, "OAuth2 로그인에 실패했습니다."), + LOGIN_CODE_INVALID(4012, "로그인 교환 코드가 유효하지 않습니다."), + LOGIN_TOKEN_INVALID(4013, "로그인 access token이 유효하지 않습니다."), + LOGIN_TOKEN_EXPIRED(4014, "로그인 access token이 만료되었습니다."), + REFRESH_TOKEN_INVALID(4015, "로그인 refresh token이 유효하지 않습니다."), + REFRESH_TOKEN_REUSED(4016, "이미 사용한 refresh token이 다시 제출되었습니다."), + OAUTH2_ACCESS_TOKEN_UNSUPPORTED(4017, "OAuth2 access token 인증은 아직 지원하지 않습니다."), FORBIDDEN(4030, "인증 권한이 부적절합니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), NOT_FOUND_CLIENT(4031, "클라이언트 정보를 찾을 수 없습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), INVALID_API_KEY(4032, "API 키가 유효하지 않습니다. 인증 키가 유효하지 않거나 권한이 없는 경우 발생합니다."), + USER_INACTIVE(4033, "비활성 사용자 계정입니다."), TOO_MANY_REQUESTS(4290, "요청 횟수가 제한이 초과 되었습니다. 일정 시간이 지나면 다시 시도해 주세요."), INVALID_TRACKING_ID(4002, "유효하지 않은 Tracking ID 입니다. Tracking ID를 확인해 주세요."), INVALID_CALLBACK_URL(4001, "콜백 URL 형식이 올바르지 않습니다. 콜백 URL을 확인해 주세요."), + AMBIGUOUS_CREDENTIALS(4004, "하나의 요청에는 하나의 인증 정보만 전달할 수 있습니다."), INTERNAL_SERVER_ERROR(5000, "서버 내부 오류가 발생 했습니다., 재시도 시 문제가 지속 되는 경우 관리자에게 문의 바랍니다."), SERVICE_UNAVAILABLE(5030, "현재 서비스가 점검중이므로 서비스 응답할 수 없는 경우 발생합니다."), ; diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginExchangeCodeRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginExchangeCodeRepository.java new file mode 100644 index 0000000..67c4a45 --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginExchangeCodeRepository.java @@ -0,0 +1,22 @@ +package app.storage.rds; + +import app.domain.auth.LoginExchangeCode; +import app.domain.auth.LoginExchangeCodeRepository; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaLoginExchangeCodeRepository + extends LoginExchangeCodeRepository, JpaRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from login_exchange_codes c where c.codeHash = :codeHash") + Optional findByCodeHashForUpdate(@Param("codeHash") String codeHash); +} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshSessionRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshSessionRepository.java new file mode 100644 index 0000000..fe80c03 --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshSessionRepository.java @@ -0,0 +1,22 @@ +package app.storage.rds; + +import app.domain.auth.LoginRefreshSession; +import app.domain.auth.LoginRefreshSessionRepository; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaLoginRefreshSessionRepository + extends LoginRefreshSessionRepository, JpaRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from login_refresh_sessions s where s.id = :id") + Optional findByIdForUpdate(@Param("id") UUID id); +} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshTokenRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshTokenRepository.java new file mode 100644 index 0000000..7bcfafb --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaLoginRefreshTokenRepository.java @@ -0,0 +1,22 @@ +package app.storage.rds; + +import app.domain.auth.LoginRefreshToken; +import app.domain.auth.LoginRefreshTokenRepository; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaLoginRefreshTokenRepository + extends LoginRefreshTokenRepository, JpaRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from login_refresh_tokens t where t.tokenHash = :tokenHash") + Optional findByTokenHashForUpdate(@Param("tokenHash") String tokenHash); +} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaOAuthAccountRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaOAuthAccountRepository.java new file mode 100644 index 0000000..ee78b26 --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaOAuthAccountRepository.java @@ -0,0 +1,28 @@ +package app.storage.rds; + +import app.domain.user.OAuthAccount; +import app.domain.user.OAuthAccountRepository; +import app.domain.user.OAuthProvider; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaOAuthAccountRepository + extends OAuthAccountRepository, JpaRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select a from oauth_accounts a + where a.provider = :provider and a.providerUserId = :providerUserId + """) + Optional findByProviderAndProviderUserIdForUpdate( + @Param("provider") OAuthProvider provider, @Param("providerUserId") String providerUserId); +} diff --git a/profanity-storage/rdb/src/main/java/app/storage/rds/JpaUserAccountRepository.java b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaUserAccountRepository.java new file mode 100644 index 0000000..af00819 --- /dev/null +++ b/profanity-storage/rdb/src/main/java/app/storage/rds/JpaUserAccountRepository.java @@ -0,0 +1,27 @@ +package app.storage.rds; + +import app.domain.user.UserAccount; +import app.domain.user.UserAccountRepository; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaUserAccountRepository + extends UserAccountRepository, JpaRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from users u where u.id = :id") + Optional findByIdForUpdate(@Param("id") UUID id); + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from users u where u.primaryEmail = :primaryEmail") + Optional findByPrimaryEmailForUpdate(@Param("primaryEmail") String primaryEmail); +} diff --git a/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql b/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql index 9c2d94f..c5def1f 100644 --- a/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql +++ b/profanity-storage/rdb/src/main/resources/db/migration/V2__oauth_2.sql @@ -2,13 +2,13 @@ CREATE TABLE users ( id binary (16) NOT NULL COMMENT '사용자 고유 식별자', display_name varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '표시 이름', - primary_email varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '대표 이메일', + primary_email varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT '대표 이메일', avatar_url varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '프로필 이미지 URL', status varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '사용자 상태', created_at datetime(6) NOT NULL COMMENT '생성 시각', updated_at datetime(6) NOT NULL COMMENT '수정 시각', PRIMARY KEY (id), - KEY idx_users_primary_email(primary_email), + UNIQUE KEY uk_users_primary_email(primary_email), KEY idx_users_status(status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 계정'; diff --git a/profanity-storage/rdb/src/main/resources/db/migration/V3__login_sessions.sql b/profanity-storage/rdb/src/main/resources/db/migration/V3__login_sessions.sql new file mode 100644 index 0000000..f6ab00a --- /dev/null +++ b/profanity-storage/rdb/src/main/resources/db/migration/V3__login_sessions.sql @@ -0,0 +1,51 @@ +CREATE TABLE login_exchange_codes +( + id binary(16) NOT NULL COMMENT '로그인 교환 코드 식별자', + user_id binary(16) NOT NULL COMMENT '사용자 ID', + code_hash char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'SHA-256 교환 코드 해시', + created_at datetime(6) NOT NULL COMMENT '생성 시각', + expires_at datetime(6) NOT NULL COMMENT '만료 시각', + consumed_at datetime(6) DEFAULT NULL COMMENT '소비 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_login_exchange_codes_hash (code_hash), + KEY idx_login_exchange_codes_user_id (user_id), + KEY idx_login_exchange_codes_expires_at (expires_at), + CONSTRAINT fk_login_exchange_codes_user_id + FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSO 로그인 일회용 교환 코드'; + +CREATE TABLE login_refresh_sessions +( + id binary(16) NOT NULL COMMENT 'refresh token family 식별자', + user_id binary(16) NOT NULL COMMENT '사용자 ID', + created_at datetime(6) NOT NULL COMMENT '생성 시각', + absolute_expires_at datetime(6) NOT NULL COMMENT '세션 절대 만료 시각', + last_rotated_at datetime(6) NOT NULL COMMENT '마지막 rotation 시각', + revoked_at datetime(6) DEFAULT NULL COMMENT '폐기 시각', + revoke_reason varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '폐기 사유', + PRIMARY KEY (id), + KEY idx_login_refresh_sessions_user_id (user_id), + KEY idx_login_refresh_sessions_expires_at (absolute_expires_at), + CONSTRAINT fk_login_refresh_sessions_user_id + FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='로그인 refresh token family'; + +CREATE TABLE login_refresh_tokens +( + id binary(16) NOT NULL COMMENT 'refresh token 식별자', + session_id binary(16) NOT NULL COMMENT 'refresh token family 식별자', + token_hash char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'SHA-256 refresh token 해시', + issued_at datetime(6) NOT NULL COMMENT '발급 시각', + expires_at datetime(6) NOT NULL COMMENT '만료 시각', + consumed_at datetime(6) DEFAULT NULL COMMENT 'rotation 소비 시각', + replaced_by_token_id binary(16) DEFAULT NULL COMMENT '교체된 refresh token 식별자', + PRIMARY KEY (id), + UNIQUE KEY uk_login_refresh_tokens_hash (token_hash), + KEY idx_login_refresh_tokens_session_id (session_id), + KEY idx_login_refresh_tokens_expires_at (expires_at), + KEY idx_login_refresh_tokens_replaced_by (replaced_by_token_id), + CONSTRAINT fk_login_refresh_tokens_session_id + FOREIGN KEY (session_id) REFERENCES login_refresh_sessions (id), + CONSTRAINT fk_login_refresh_tokens_replaced_by + FOREIGN KEY (replaced_by_token_id) REFERENCES login_refresh_tokens (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='rotating refresh token'; diff --git a/profanity-test-support/build.gradle b/profanity-test-support/build.gradle index 0789c73..83be1b0 100644 --- a/profanity-test-support/build.gradle +++ b/profanity-test-support/build.gradle @@ -9,6 +9,7 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' testImplementation project(':profanity-storage:rdb') + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa' testImplementation 'org.testcontainers:testcontainers-junit-jupiter' } diff --git a/profanity-test-support/src/main/resources/db/seed/0001.sql b/profanity-test-support/src/main/resources/db/seed/0001.sql index 37161a9..4e3b1d5 100644 --- a/profanity-test-support/src/main/resources/db/seed/0001.sql +++ b/profanity-test-support/src/main/resources/db/seed/0001.sql @@ -1,5 +1,10 @@ SET FOREIGN_KEY_CHECKS = 0; +DELETE FROM `login_refresh_tokens`; +DELETE FROM `login_refresh_sessions`; +DELETE FROM `login_exchange_codes`; +DELETE FROM `oauth_accounts`; +DELETE FROM `users`; DELETE FROM `client_reports`; DELETE FROM `records`; DELETE FROM `word_management`; diff --git a/profanity-test-support/src/test/java/app/test/support/container/LoginAuthPersistenceIntegrationTest.java b/profanity-test-support/src/test/java/app/test/support/container/LoginAuthPersistenceIntegrationTest.java new file mode 100644 index 0000000..0fe8302 --- /dev/null +++ b/profanity-test-support/src/test/java/app/test/support/container/LoginAuthPersistenceIntegrationTest.java @@ -0,0 +1,273 @@ +package app.test.support.container; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.application.auth.LoginExchangeCodeConsumeResult; +import app.application.auth.LoginExchangeCodeConsumeStatus; +import app.application.auth.LoginExchangeCodeService; +import app.application.auth.LoginRefreshRotationResult; +import app.application.auth.LoginRefreshRotationStatus; +import app.application.auth.LoginRefreshSessionIssue; +import app.application.auth.LoginRefreshTokenService; +import app.application.auth.SsoAccountService; +import app.application.auth.SsoAccountTransactionService; +import app.domain.auth.RefreshSessionRevocationReason; +import app.domain.auth.Sha256Hash; +import app.domain.user.OAuthLoginProfile; +import app.domain.user.OAuthProvider; +import app.domain.user.UserAccount; +import app.storage.rds.JpaLoginExchangeCodeRepository; +import app.storage.rds.JpaLoginRefreshSessionRepository; +import app.storage.rds.JpaLoginRefreshTokenRepository; +import app.storage.rds.JpaOAuthAccountRepository; +import app.storage.rds.JpaUserAccountRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mysql.MySQLContainer; + +@Testcontainers +@SpringBootTest( + classes = LoginAuthPersistenceIntegrationTest.TestApplication.class, + properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.open-in-view=false", + "spring.flyway.enabled=true" + }) +class LoginAuthPersistenceIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-07-11T00:00:00Z"); + private static final Duration REFRESH_TTL = Duration.ofDays(14); + private static final Duration ABSOLUTE_TTL = Duration.ofDays(30); + private static final Duration GRACE = Duration.ofSeconds(3); + + @Container private static final MySQLContainer MYSQL = MySqlTestContainer.create(); + + @Autowired private SsoAccountService ssoAccountService; + @Autowired private LoginExchangeCodeService exchangeCodeService; + @Autowired private LoginRefreshTokenService refreshTokenService; + @Autowired private JpaUserAccountRepository userRepository; + @Autowired private JpaOAuthAccountRepository oauthRepository; + @Autowired private JpaLoginExchangeCodeRepository exchangeCodeRepository; + @Autowired private JpaLoginRefreshSessionRepository sessionRepository; + @Autowired private JpaLoginRefreshTokenRepository tokenRepository; + + private ExecutorService executor; + + @DynamicPropertySource + static void configureDataSource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + @BeforeEach + void setUp() { + executor = Executors.newFixedThreadPool(2); + tokenRepository.deleteAllInBatch(); + sessionRepository.deleteAllInBatch(); + exchangeCodeRepository.deleteAllInBatch(); + oauthRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + @DisplayName("동일한 SSO identity의 동시 최초 로그인은 내부 사용자 하나로 수렴한다") + void upsert_whenFirstLoginIsConcurrent_createsOneUser() throws Exception { + OAuthLoginProfile profile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "concurrent-provider-user", + "concurrent@example.com", + true, + true, + "concurrent-user", + "Concurrent User", + null); + CyclicBarrier barrier = new CyclicBarrier(2); + + Future first = + executor.submit( + () -> { + barrier.await(); + return ssoAccountService.upsert(profile, NOW); + }); + Future second = + executor.submit( + () -> { + barrier.await(); + return ssoAccountService.upsert(profile, NOW); + }); + + UserAccount firstUser = first.get(); + UserAccount secondUser = second.get(); + + assertThat(firstUser.getId()).isEqualTo(secondUser.getId()); + assertThat(userRepository.count()).isEqualTo(1); + assertThat(oauthRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("동일한 신뢰 이메일의 서로 다른 provider 동시 로그인은 내부 사용자 하나에 연결된다") + void upsert_whenDifferentProvidersWithSameAuthoritativeEmailAreConcurrent_linksOneUser() + throws Exception { + OAuthLoginProfile githubProfile = + new OAuthLoginProfile( + OAuthProvider.GITHUB, + "concurrent-github-user", + "same@gmail.com", + true, + true, + "github-user", + "GitHub User", + null); + OAuthLoginProfile googleProfile = + new OAuthLoginProfile( + OAuthProvider.GOOGLE, + "concurrent-google-user", + "Same@Gmail.COM", + true, + true, + "google-user", + "Google User", + null); + CyclicBarrier barrier = new CyclicBarrier(2); + + Future github = + executor.submit( + () -> { + barrier.await(); + return ssoAccountService.upsert(githubProfile, NOW); + }); + Future google = + executor.submit( + () -> { + barrier.await(); + return ssoAccountService.upsert(googleProfile, NOW); + }); + + UserAccount githubUser = github.get(); + UserAccount googleUser = google.get(); + + assertThat(githubUser.getId()).isEqualTo(googleUser.getId()); + assertThat(githubUser.getPrimaryEmail()).isEqualTo("same@gmail.com"); + assertThat(userRepository.count()).isEqualTo(1); + assertThat(oauthRepository.count()).isEqualTo(2); + } + + @Test + @DisplayName("동일한 refresh token의 동시 rotation은 한 요청만 성공하고 family를 유지한다") + void rotate_whenRequestsAreConcurrent_rotatesExactlyOnceWithinGrace() throws Exception { + UserAccount user = + userRepository.saveAndFlush( + UserAccount.create("Concurrent User", "concurrent@example.com", null, NOW)); + Sha256Hash currentHash = hash('a'); + LoginRefreshSessionIssue issue = + refreshTokenService.createSession( + user.getId(), currentHash, NOW, REFRESH_TTL, ABSOLUTE_TTL); + Instant rotatedAt = NOW.plusSeconds(1); + CyclicBarrier barrier = new CyclicBarrier(2); + + Future first = + executor.submit( + () -> { + barrier.await(); + return refreshTokenService.rotate( + currentHash, hash('b'), rotatedAt, REFRESH_TTL, GRACE); + }); + Future second = + executor.submit( + () -> { + barrier.await(); + return refreshTokenService.rotate( + currentHash, hash('c'), rotatedAt, REFRESH_TTL, GRACE); + }); + + List statuses = + List.of(first.get().status(), second.get().status()); + + assertThat(statuses) + .containsExactlyInAnyOrder( + LoginRefreshRotationStatus.ROTATED, LoginRefreshRotationStatus.REUSED_WITHIN_GRACE); + assertThat(sessionRepository.findById(issue.sessionId()).orElseThrow().isRevoked()).isFalse(); + assertThat(tokenRepository.count()).isEqualTo(2); + + LoginRefreshRotationResult replay = + refreshTokenService.rotate( + currentHash, hash('d'), rotatedAt.plus(GRACE).plusNanos(1), REFRESH_TTL, GRACE); + + assertThat(replay.status()) + .isEqualTo(LoginRefreshRotationStatus.REUSE_DETECTED_SESSION_REVOKED); + assertThat(sessionRepository.findById(issue.sessionId()).orElseThrow().getRevokeReason()) + .isEqualTo(RefreshSessionRevocationReason.TOKEN_REUSE_DETECTED); + } + + @Test + @DisplayName("동일한 로그인 교환 코드의 동시 소비는 한 요청만 성공한다") + void consume_whenExchangeRequestsAreConcurrent_consumesExactlyOnce() throws Exception { + UserAccount user = + userRepository.saveAndFlush( + UserAccount.create("Concurrent User", "concurrent@example.com", null, NOW)); + Sha256Hash codeHash = hash('e'); + exchangeCodeService.issue(user.getId(), codeHash, NOW, Duration.ofSeconds(60)); + CyclicBarrier barrier = new CyclicBarrier(2); + + Future first = + executor.submit( + () -> { + barrier.await(); + return exchangeCodeService.consume(codeHash, NOW.plusSeconds(1)); + }); + Future second = + executor.submit( + () -> { + barrier.await(); + return exchangeCodeService.consume(codeHash, NOW.plusSeconds(1)); + }); + + assertThat(List.of(first.get().status(), second.get().status())) + .containsExactlyInAnyOrder( + LoginExchangeCodeConsumeStatus.CONSUMED, + LoginExchangeCodeConsumeStatus.ALREADY_CONSUMED); + } + + private static Sha256Hash hash(char value) { + return new Sha256Hash(String.valueOf(value).repeat(64)); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackages = "app.domain") + @EnableJpaRepositories(basePackages = "app.storage.rds") + @Import({ + SsoAccountService.class, + SsoAccountTransactionService.class, + LoginExchangeCodeService.class, + LoginRefreshTokenService.class + }) + static class TestApplication {} +} diff --git a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java index a5fc410..2a50d64 100644 --- a/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java +++ b/profanity-test-support/src/test/java/app/test/support/container/MySqlTestContainerSmokeTest.java @@ -26,8 +26,15 @@ void mysqlContainer_whenMigrateAndSeed_succeeds() throws SQLException { DataSource dataSource = MySqlTestContainer.dataSource(MYSQL); + assertThat(columnNullability(dataSource, "users", "primary_email")).isEqualTo("NO"); + assertThat(columnCollation(dataSource, "users", "primary_email")).isEqualTo("utf8mb4_bin"); + assertThat(uniqueIndexCount(dataSource, "users", "primary_email")).isEqualTo(1); assertThat(count(dataSource, "clients")).isEqualTo(2); assertThat(count(dataSource, "profanity_word")).isEqualTo(3); + assertThat(count(dataSource, "users")).isZero(); + assertThat(count(dataSource, "login_exchange_codes")).isZero(); + assertThat(count(dataSource, "login_refresh_sessions")).isZero(); + assertThat(count(dataSource, "login_refresh_tokens")).isZero(); execute( dataSource, @@ -41,9 +48,86 @@ insert into clients (id, name, email, api_key, issuer_info, permissions) 'READ') """); + execute( + dataSource, + """ + insert into users (id, display_name, primary_email, status, created_at, updated_at) + values (UNHEX(REPLACE('10000000-0000-0000-0000-000000000001', '-', '')), + 'Temporary Login User', + 'temporary-login@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6)) + """); + execute( + dataSource, + """ + insert into users (id, display_name, primary_email, status, created_at, updated_at) + values (UNHEX(REPLACE('10000000-0000-0000-0000-000000000011', '-', '')), + 'Accent Plain User', + 'jose@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6)), + (UNHEX(REPLACE('10000000-0000-0000-0000-000000000012', '-', '')), + 'Accent User', + 'josé@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6)), + (UNHEX(REPLACE('10000000-0000-0000-0000-000000000013', '-', '')), + 'Expansion Plain User', + 'strasse@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6)), + (UNHEX(REPLACE('10000000-0000-0000-0000-000000000014', '-', '')), + 'Expansion User', + 'straße@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP(6), + CURRENT_TIMESTAMP(6)) + """); + execute( + dataSource, + """ + insert into login_exchange_codes (id, user_id, code_hash, created_at, expires_at) + values (UNHEX(REPLACE('10000000-0000-0000-0000-000000000002', '-', '')), + UNHEX(REPLACE('10000000-0000-0000-0000-000000000001', '-', '')), + REPEAT('a', 64), + CURRENT_TIMESTAMP(6), + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 60 SECOND)) + """); + execute( + dataSource, + """ + insert into login_refresh_sessions + (id, user_id, created_at, absolute_expires_at, last_rotated_at) + values (UNHEX(REPLACE('10000000-0000-0000-0000-000000000003', '-', '')), + UNHEX(REPLACE('10000000-0000-0000-0000-000000000001', '-', '')), + CURRENT_TIMESTAMP(6), + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 30 DAY), + CURRENT_TIMESTAMP(6)) + """); + execute( + dataSource, + """ + insert into login_refresh_tokens + (id, session_id, token_hash, issued_at, expires_at) + values (UNHEX(REPLACE('10000000-0000-0000-0000-000000000004', '-', '')), + UNHEX(REPLACE('10000000-0000-0000-0000-000000000003', '-', '')), + REPEAT('b', 64), + CURRENT_TIMESTAMP(6), + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 14 DAY)) + """); + MySqlTestContainer.resetSeedData(MYSQL); assertThat(count(dataSource, "clients")).isEqualTo(2); + assertThat(count(dataSource, "users")).isZero(); + assertThat(count(dataSource, "login_exchange_codes")).isZero(); + assertThat(count(dataSource, "login_refresh_sessions")).isZero(); + assertThat(count(dataSource, "login_refresh_tokens")).isZero(); } private static int count(DataSource dataSource, String tableName) throws SQLException { @@ -55,6 +139,70 @@ private static int count(DataSource dataSource, String tableName) throws SQLExce } } + private static String columnNullability( + DataSource dataSource, String tableName, String columnName) throws SQLException { + try (Connection connection = dataSource.getConnection(); + var statement = + connection.prepareStatement( + """ + select IS_NULLABLE + from information_schema.columns + where table_schema = database() and table_name = ? and column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + throw new SQLException("Column metadata not found: " + tableName + "." + columnName); + } + return resultSet.getString("IS_NULLABLE"); + } + } + } + + private static int uniqueIndexCount(DataSource dataSource, String tableName, String columnName) + throws SQLException { + try (Connection connection = dataSource.getConnection(); + var statement = + connection.prepareStatement( + """ + select count(distinct INDEX_NAME) + from information_schema.statistics + where table_schema = database() + and table_name = ? + and column_name = ? + and NON_UNIQUE = 0 + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getInt(1); + } + } + } + + private static String columnCollation(DataSource dataSource, String tableName, String columnName) + throws SQLException { + try (Connection connection = dataSource.getConnection(); + var statement = + connection.prepareStatement( + """ + select COLLATION_NAME + from information_schema.columns + where table_schema = database() and table_name = ? and column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + throw new SQLException("Column metadata not found: " + tableName + "." + columnName); + } + return resultSet.getString("COLLATION_NAME"); + } + } + } + private static void execute(DataSource dataSource, String sql) throws SQLException { try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { diff --git a/ui/sample-app/README.md b/ui/sample-app/README.md index 1559128..3c7eae4 100644 --- a/ui/sample-app/README.md +++ b/ui/sample-app/README.md @@ -29,6 +29,14 @@ npm run preview Vite의 기본 흐름은 `npm run dev`로 개발 서버를 띄우고, `npm run build`로 `dist/` 정적 파일을 만드는 방식입니다. +로그인 API는 기본적으로 `http://localhost:8080`을 사용합니다. 다른 API 호스트를 사용할 때는 빌드 또는 개발 서버 실행 전에 공개 설정값인 `VITE_API_BASE_URL`을 지정합니다. + +```bash +VITE_API_BASE_URL=https://api.example.com npm run dev +``` + +로그인 access token은 React 실행 메모리에만 두고, refresh token은 서버가 발급한 `HttpOnly` cookie로만 사용합니다. 브라우저를 새로고침하면 `/api/v1/auth/csrf` 확인 후 `/api/v1/auth/refresh`를 한 번만 호출해 로그인 상태를 복구합니다. + ## 폴더 구조 ```text diff --git a/ui/sample-app/package.json b/ui/sample-app/package.json index 5e63c15..d8e3952 100644 --- a/ui/sample-app/package.json +++ b/ui/sample-app/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", + "test": "npm run test:register && npm run test:docs && npm run test:login", + "test:login": "node scripts/check-login-auth.mjs", "test:register": "node scripts/check-register-flow.mjs", "test:docs": "node scripts/check-docs-layout.mjs", "typecheck": "tsc --noEmit" diff --git a/ui/sample-app/scripts/check-login-auth.mjs b/ui/sample-app/scripts/check-login-auth.mjs new file mode 100644 index 0000000..d200a6c --- /dev/null +++ b/ui/sample-app/scripts/check-login-auth.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const appRoot = resolve(import.meta.dirname, ".."); +const loginPage = readFileSync(resolve(appRoot, "src", "features", "login", "LoginPage.tsx"), "utf8"); +const packageJson = JSON.parse(readFileSync(resolve(appRoot, "package.json"), "utf8")); + +assert.match(loginPage, /VITE_API_BASE_URL/, "Login API base URL must support a Vite environment override."); +assert.match(loginPage, /DEFAULT_API_BASE_URL = "http:\/\/localhost:8080"/, "Local login development must keep the existing API default."); + +assert.match(loginPage, /params\.getAll\("code"\)/, "OAuth callback must consume only the one-time code fragment."); +assert.match(loginPage, /window\.history\.replaceState/, "OAuth callback code must be removed from browser history immediately."); +assert.match(loginPage, /\/api\/v1\/auth\/exchange/, "Login callback code must be exchanged through the auth API."); +assert.match(loginPage, /body: JSON\.stringify\(\{ code \}\)/, "Exchange must send the callback code in a JSON request body."); + +assert.match(loginPage, /\/api\/v1\/auth\/csrf/, "Session restoration must obtain a CSRF token first."); +assert.match(loginPage, /\/api\/v1\/auth\/refresh/, "Session restoration must rotate the refresh cookie through the refresh API."); +assert.match(loginPage, /refreshFlightRef\.current/, "Refresh requests must share one in-flight promise."); +assert.match(loginPage, /if \(refreshFlightRef\.current\)/, "Refresh must return the existing in-flight request before starting another rotation."); +assert.match(loginPage, /credentials: "include"/, "Every auth request must include the HttpOnly refresh cookie managed by the browser."); + +assert.match(loginPage, /useState\(null\)/, "The access token must live only in React state."); +assert.match(loginPage, /\/api\/v1\/auth\/me/, "The issued access token must be verified through the current-user endpoint."); +assert.match(loginPage, /Authorization: `\$\{tokenType\} \$\{accessToken\}`/, "Current-user requests must authenticate with the in-memory access token."); + +assert.doesNotMatch(loginPage, /\blocalStorage\b/, "Login credentials must never be stored in localStorage."); +assert.doesNotMatch(loginPage, /\bsessionStorage\b/, "Login credentials must never be stored in sessionStorage."); +assert.doesNotMatch(loginPage, /document\.cookie/, "JavaScript must not read or write the refresh cookie."); +assert.doesNotMatch(loginPage, /console\.(?:log|info|debug|warn|error)/, "Login credentials and provider responses must not be logged."); +assert.doesNotMatch(loginPage, /; +type LoginUser = { + id: string; + displayName: string; + email: string; + avatarUrl: string | null; +}; + +type AuthSession = { + accessToken: string; + tokenType: "Bearer"; + expiresIn: number; + user: LoginUser; +}; + +type AuthView = + | { state: "checking"; message: string } + | { state: "anonymous"; message: string } + | { state: "error"; message: string } + | { state: "authenticated"; user: LoginUser; expiresIn: number }; + +type LoginCallback = + | { state: "none" } + | { state: "invalid" } + | { state: "ready"; code: string }; + +type CsrfToken = { + token: string; + headerName: string; +}; + +class AuthRequestError extends Error { + constructor(readonly status: number) { + super("Authentication request failed"); + this.name = "AuthRequestError"; + } +} export function LoginPage() { - const [loginResult, setLoginResult] = useState(() => parseLoginResult()); + const [accessToken, setAccessToken] = useState(null); + const [authView, setAuthView] = useState({ + state: "checking", + message: "로그인 상태를 확인하고 있습니다.", + }); + const callbackRef = useRef(null); + const initializationStartedRef = useRef(false); + const refreshFlightRef = useRef | null>(null); + + if (callbackRef.current === null) { + callbackRef.current = readLoginCallback(); + } + + const verifySession = useCallback(async (session: AuthSession) => { + setAccessToken(session.accessToken); + setAuthView({ state: "checking", message: "로그인 정보를 확인하고 있습니다." }); + + try { + const user = await fetchCurrentUser(session.accessToken, session.tokenType); + setAuthView({ state: "authenticated", user, expiresIn: session.expiresIn }); + } catch { + setAccessToken(null); + setAuthView({ + state: "error", + message: "로그인 정보를 확인하지 못했습니다. 다시 로그인해 주세요.", + }); + } + }, []); - useEffect(() => { - function syncLoginResult() { - setLoginResult(parseLoginResult()); + const refreshSession = useCallback(() => { + if (refreshFlightRef.current) { + return refreshFlightRef.current; } - window.addEventListener("hashchange", syncLoginResult); - return () => window.removeEventListener("hashchange", syncLoginResult); + const refreshFlight = requestRefreshSession().finally(() => { + if (refreshFlightRef.current === refreshFlight) { + refreshFlightRef.current = null; + } + }); + + refreshFlightRef.current = refreshFlight; + return refreshFlight; }, []); - const formattedResult = useMemo(() => { - if (!loginResult) { - return LOGIN_RESULT_EMPTY; + const restoreSession = useCallback(async () => { + setAuthView({ state: "checking", message: "로그인 상태를 복구하고 있습니다." }); + + try { + const session = await refreshSession(); + await verifySession(session); + } catch (error) { + setAccessToken(null); + setAuthView(toRefreshFailureView(error)); + } + }, [refreshSession, verifySession]); + + useLayoutEffect(() => { + if (initializationStartedRef.current) { + return; } - return JSON.stringify(loginResult, null, 2); - }, [loginResult]); + initializationStartedRef.current = true; + const callback = callbackRef.current ?? { state: "none" }; + callbackRef.current = { state: "none" }; + removeLoginFragment(); + + if (callback.state === "invalid") { + setAuthView({ + state: "error", + message: "로그인 응답을 확인할 수 없습니다. 소셜 로그인을 다시 시작해 주세요.", + }); + return; + } + + if (callback.state === "ready") { + setAuthView({ state: "checking", message: "소셜 로그인을 완료하고 있습니다." }); + void exchangeLoginCode(callback.code) + .then(verifySession) + .catch(() => { + setAccessToken(null); + setAuthView({ + state: "error", + message: "로그인을 완료하지 못했습니다. 소셜 로그인을 다시 시작해 주세요.", + }); + }); + return; + } + + void restoreSession(); + }, [restoreSession, verifySession]); function startOAuthLogin(provider: LoginProvider) { window.location.assign(`${API_BASE_URL}/oauth2/authorization/${provider}`); } + async function checkCurrentSession() { + if (!accessToken) { + await restoreSession(); + return; + } + + const expiresIn = authView.state === "authenticated" ? authView.expiresIn : 0; + setAuthView({ state: "checking", message: "로그인 상태를 확인하고 있습니다." }); + try { + const user = await fetchCurrentUser(accessToken, "Bearer"); + setAuthView({ state: "authenticated", user, expiresIn }); + } catch { + setAccessToken(null); + setAuthView({ + state: "anonymous", + message: "로그인 세션이 만료되었습니다. 다시 로그인해 주세요.", + }); + } + } + + const isChecking = authView.state === "checking"; + return (
@@ -41,37 +170,271 @@ export function LoginPage() {
- -
- + void checkCurrentSession()} />
); } -function parseLoginResult(): OAuthLoginResult | null { - const hash = window.location.hash.replace(/^#/, ""); +function LoginStatusPanel({ authView, onCheckSession }: { authView: AuthView; onCheckSession: () => void }) { + if (authView.state === "authenticated") { + const avatarUrl = toSafeAvatarUrl(authView.user.avatarUrl); + const expiresInMinutes = Math.max(1, Math.ceil(authView.expiresIn / 60)); + + return ( + + ); + } + + return ( + + ); +} +function readLoginCallback(): LoginCallback { + const hash = window.location.hash.replace(/^#/, ""); if (!hash) { - return null; + return { state: "none" }; } const params = new URLSearchParams(hash); - const result = Object.fromEntries(params.entries()); + const codes = params.getAll("code"); + const hasOnlyCode = Array.from(params.keys()).every((key) => key === "code"); + + if (!hasOnlyCode || codes.length !== 1 || codes[0].length === 0 || codes[0].length > 4096) { + return { state: "invalid" }; + } + + return { state: "ready", code: codes[0] }; +} + +function removeLoginFragment() { + if (!window.location.hash) { + return; + } + + const cleanUrl = `${window.location.pathname}${window.location.search}`; + window.history.replaceState(window.history.state, "", cleanUrl); +} + +async function exchangeLoginCode(code: string): Promise { + const data = await requestApiData("/api/v1/auth/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + + return readAuthSession(data); +} + +async function requestRefreshSession(): Promise { + const csrfData = await requestApiData("/api/v1/auth/csrf", { method: "GET" }); + const csrfToken = readCsrfToken(csrfData); + const data = await requestApiData("/api/v1/auth/refresh", { + method: "POST", + headers: { [csrfToken.headerName]: csrfToken.token }, + }); + + return readAuthSession(data); +} + +async function fetchCurrentUser(accessToken: string, tokenType: "Bearer"): Promise { + const data = await requestApiData("/api/v1/auth/me", { + method: "GET", + headers: { Authorization: `${tokenType} ${accessToken}` }, + }); + + const record = toRecord(data); + return readLoginUser("user" in record ? record.user : record); +} + +async function requestApiData(path: string, init: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + }); + + if (!response.ok) { + throw new AuthRequestError(response.status); + } + + const payload: unknown = await response.json(); + const envelope = toRecord(payload); + if (!("data" in envelope)) { + throw new AuthRequestError(response.status); + } + + return envelope.data; +} + +function readAuthSession(value: unknown): AuthSession { + const record = toRecord(value); + const accessToken = readRequiredString(record, "accessToken"); + const tokenType = readRequiredString(record, "tokenType"); + const expiresIn = record.expiresIn; + + if ( + tokenType.toLowerCase() !== "bearer" || + typeof expiresIn !== "number" || + !Number.isFinite(expiresIn) || + expiresIn <= 0 + ) { + throw new TypeError("Invalid authentication session"); + } + + return { + accessToken, + tokenType: "Bearer", + expiresIn, + user: readLoginUser(record.user), + }; +} + +function readLoginUser(value: unknown): LoginUser { + const record = toRecord(value); + const id = record.id; + + if ((typeof id !== "string" && typeof id !== "number") || String(id).length === 0) { + throw new TypeError("Invalid login user"); + } + + return { + id: String(id), + displayName: readRequiredString(record, "displayName"), + email: readRequiredString(record, "email"), + avatarUrl: readNullableString(record, "avatarUrl"), + }; +} + +function readCsrfToken(value: unknown): CsrfToken { + const record = toRecord(value); + const headerName = readRequiredString(record, "headerName"); + + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(headerName)) { + throw new TypeError("Invalid CSRF header name"); + } + + return { + token: readRequiredString(record, "token"), + headerName, + }; +} + +function readRequiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`Invalid ${key}`); + } + return value; +} + +function readNullableString(record: Record, key: string): string | null { + const value = record[key]; + if (value === null || value === undefined) { + return null; + } + if (typeof value !== "string") { + throw new TypeError(`Invalid ${key}`); + } + return value; +} + +function toRecord(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Invalid API response"); + } + return value as Record; +} + +function toRefreshFailureView(error: unknown): AuthView { + if (error instanceof AuthRequestError && (error.status === 401 || error.status === 403)) { + return { + state: "anonymous", + message: "로그인이 필요합니다. GitHub 또는 Google 계정으로 시작해 주세요.", + }; + } + + return { + state: "error", + message: "로그인 상태를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.", + }; +} + +function toSafeAvatarUrl(value: string | null): string | null { + if (!value) { + return null; + } + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null; + } catch { + return null; + } +} - return Object.keys(result).length > 0 ? result : null; +function resolveApiBaseUrl(value: string | undefined): string { + const configuredUrl = value?.trim().replace(/\/+$/, ""); + return configuredUrl || DEFAULT_API_BASE_URL; } function GitHubIcon() { diff --git a/ui/sample-app/src/styles.css b/ui/sample-app/src/styles.css index 68b2d63..02b3b17 100644 --- a/ui/sample-app/src/styles.css +++ b/ui/sample-app/src/styles.css @@ -1520,6 +1520,11 @@ button { outline: 0; } +.login-provider-button:disabled { + cursor: wait; + opacity: 0.56; +} + .login-provider-icon { width: 24px; height: 24px; @@ -1546,30 +1551,103 @@ button { font-weight: 850; } -.login-result-panel pre { +.login-status-message, +.login-user-card { min-width: 0; max-width: 100%; - overflow: auto; - margin: 0; border: 1px solid color-mix(in srgb, var(--ivory) 24%, transparent); border-radius: 8px; background: color-mix(in srgb, black 20%, var(--pine)); padding: 18px; color: var(--sage); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 14px; line-height: 1.6; - white-space: pre-wrap; overflow-wrap: anywhere; } -.login-result-panel pre[data-empty="true"] { +.login-status-message { display: grid; place-items: center; + align-content: center; + gap: 18px; color: color-mix(in srgb, var(--ivory) 62%, transparent); text-align: center; } +.login-status-message p, +.login-user-card p { + margin: 0; +} + +.login-status-message[data-state="error"] { + color: var(--ivory); +} + +.login-user-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-content: center; + gap: 18px; +} + +.login-user-avatar { + width: 64px; + height: 64px; + border: 1px solid color-mix(in srgb, var(--ivory) 42%, transparent); + border-radius: 50%; + object-fit: cover; +} + +.login-user-avatar-fallback { + display: grid; + place-items: center; + background: var(--sage); + color: var(--pine); + font-size: 24px; + font-weight: 900; +} + +.login-user-name { + color: var(--ivory); + font-size: clamp(22px, 2.6vw, 30px); + font-weight: 900; + line-height: 1.2; +} + +.login-user-email { + margin-top: 4px !important; + color: color-mix(in srgb, var(--ivory) 68%, transparent); + font-size: 14px; +} + +.login-session-note, +.login-session-button { + grid-column: 1 / -1; +} + +.login-session-note { + border-top: 1px solid color-mix(in srgb, var(--ivory) 18%, transparent); + padding-top: 16px; + color: color-mix(in srgb, var(--ivory) 68%, transparent); + font-size: 13px; +} + +.login-session-button { + min-height: 46px; + border: 1px solid color-mix(in srgb, var(--ivory) 46%, transparent); + border-radius: 8px; + background: transparent; + padding: 0 16px; + color: var(--ivory); + font-weight: 850; +} + +.login-session-button:hover, +.login-session-button:focus-visible { + background: var(--sage); + color: var(--pine); + outline: 0; +} + .footer-cta { display: grid; justify-items: center; diff --git a/ui/sample-app/src/vite-env.d.ts b/ui/sample-app/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/sample-app/src/vite-env.d.ts @@ -0,0 +1 @@ +///