DomainAuth Model

Auth Model (OperatorAccount · RefreshToken · JWT)

SPEC #003 정합 (+ #022 enforcement · #049 CurrentPrincipal·scope guard).

Overview

모든 사용자는 단일 entity OperatorAccount. role 로 권한 분기. JWT 는 access (15min, HS256) + refresh (7d, opaque random, DB 저장 hash + rotation).

OperatorAccount Entity

컬럼타입비고
idUUIDPK (BaseEntity Style.TIME)
emailStringUNIQUE NOT NULL
passwordHashStringNOT NULL · BCrypt strength=12 (60자)
nameString?nullable
roleRoleOPERATOR / HQ_MANAGER / STORE_MANAGER
hqIdUUID?HQ_MANAGER 필수 · 그 외 NULL
storeIdUUID?STORE_MANAGER 필수 · 그 외 NULL
statusAccountStatusACTIVE / SUSPENDED / WITHDRAWN
passwordMustChangeBooleandefault false. 발급 계정(HQ_MANAGER·STORE_MANAGER)의 임시 비번 → true. BE 403 filter(전 role) + FE redirect(현재 로그인 지원 role = OPERATOR·HQ_MANAGER) 이중 강제 (#022) — 아래 §passwordMustChange enforcement
lastLoginAtInstant?login 성공 시 업데이트
createdAt/updatedAt/deletedAt/createdBy/updatedByBaseEntity

도메인 invariant

  • role = HQ_MANAGERhqId NOT NULL, storeId NULL
  • role = STORE_MANAGERstoreId NOT NULL, hqId NULL (storeId 의 hqId 가 묶음 본사)
  • role = OPERATORhqId NULL, storeId NULL

생성자에서 require(...) 로 검증. 위반 시 IllegalArgumentException.

Role Enum

enum class Role { OPERATOR, HQ_MANAGER, STORE_MANAGER }
Role의미v1 구현 상태
OPERATOR운영사 (linkmusic 내부)도입 — login + 모든 admin API. 로그인 후 ops 셸(/)
HQ_MANAGER본사 관리자도입 — 직접 로그인 시 본사 모드 셸(/admin)로 진입 (SPEC #016) · 본인 프로필 getHqMe(/api/v1/hq/me, SPEC #049)
STORE_MANAGER점장entity·계정 라이프사이클(#021·#029 운영사측 발급/관리) · 본인 프로필 getStoreMe(/api/v1/store/me, SPEC #049). apps/admin 직접 로그인은 차단(아래 §로그인 후 role 라우팅) — 점장 클라이언트(Surface 12) 후속 SPEC

AccountStatus Enum

enum class AccountStatus { ACTIVE, SUSPENDED, WITHDRAWN }
  • ACTIVE — 정상
  • SUSPENDED — 로그인 차단 (구현 후속 SPEC). 즉시 세션 무효화.
  • WITHDRAWN — 탈퇴. soft-delete 의 명시적 의도.

RefreshToken Entity

컬럼타입비고
idUUIDBaseEntity (deletedAt 미사용)
accountIdUUIDFK → operator_account.id
tokenHashStringSHA-256 hex (raw token 은 client 1회 전달)
expiresAtInstant발급 + TTL
revokedAtInstant?rotation · logout 시 채움
createdAt/updatedAt(createdBy 없음 — 자기 자신)

unique: (account_id, token_hash) 복합. index: (account_id, revoked_at).

JWT 상세

  • Library: io.jsonwebtoken:jjwt-api:0.12.6 + jjwt-impl + jjwt-jackson
  • 서명: HS256 + 단일 시크릿 JWT_SECRET (env, ≥256-bit / 64자 권장)
  • Access token:
    • 수명: 15분
    • Claims: { sub, role, hqId?, storeId?, type:'access', iat, exp, impersonatedBy?, pmc }
    • impersonatedBy — 임퍼소네이션 토큰일 때만 (SPEC #005)
    • pmc (passwordMustChange) — 발급 시점(login/refresh/change-password) 계정의 passwordMustChange 현재값. PasswordChangeEnforcementFilter 가 매 요청 DB 조회 없이 이 claim 만으로 판정 (SPEC #022). 15분 access TTL 만료로 stale claim 자연 해소. 임퍼소네이션 토큰은 pmc=false 강제
  • Refresh token:
    • 형식: opaque random 32 bytes base64url (~43자)
    • DB 저장: SHA-256 hex
    • 수명: 7일 (모든 role) · 임퍼소네이션은 60분
    • rotation: refresh 호출 시 prev revokedAt = now, 새 token 발급

비밀번호 해싱

  • BCryptPasswordEncoder(strength = 12) (Spring Security 표준)
  • Argon2id 는 v1.1+ 검토

Authentication Flow

로그인 후 role 라우팅 (SPEC #016)

로그인 성공 응답(AuthResponse.role)으로 클라이언트가 분기한다. 각 레이아웃 가드가 defense-in-depth 로 같은 규칙을 server-side 에서 한 번 더 강제한다.

role로그인 후 목적지비고
OPERATORsafeNext(next) (기본 /) — ops 셸기존 동작
HQ_MANAGER/admin — 본사 모드 셸 (배너 없음)MeResponse.hqName 으로 본사명 표시
STORE_MANAGERredirect 없음 — “지원되지 않는 역할” 안내 + 세션 destroy/api/auth/logout 호출로 cookie 정리. 앱 접근 차단

passwordMustChange=true 는 role 분기보다 우선한다(임시 비번 사용자는 role 무관하게 /onboarding/change-password 로 강제). 변경 완료 후 레이아웃 가드가 role home 으로 바운스한다.

교차 리디렉션 (양방향)

  • ops 셸 (protected)/layout.tsx: me.role===HQ_MANAGERredirect("/admin").
  • 본사 모드 app/admin/layout.tsx: lm_session role===OPERATOR(임퍼소네이션 아님) → redirect("/").

/admin 이중 세션

/admin/* 본사 모드 셸은 두 진입 모드를 모두 해석한다 (우선순위: 임퍼소네이션 우선).

모드세션 cookie배너세션 관리hqName 소스
임퍼소네이션lm_impersonation_sessionO (빨강)v1 고정 60분 카운트다운 (#015)sealed 세션
실 HQ_MANAGERlm_session role=HQ_MANAGERXops 와 동일 refresh-awareMeResponse.hqName
(그 외)OPERATOR lm_session → / · 세션 없음 → /login

임퍼소네이션 세션 감사 기록 (SPEC #025)

임퍼소네이션 진입은 세션 단위로 감사 로그(ImpersonationAuditSession)에 append-only 기록된다. 기록 시점은 exchange(POST /api/v1/auth/impersonate-exchange 성공 = 실제 진입)이며 — issue(/impersonate 토큰 발급, 확인 다이얼로그)가 아니다. 진입 시 operatorEmail·hqName(스냅샷)·startedAt·expiresAt(고정 60분)를 INSERT 한다. 종료는 logout/impersonation-exit 시점에 endedAt+endReason=EXITED 로 기록되고, 종료 없이 만료된 세션은 별도 job 없이 조회 시 status 파생(EXPIRED)으로 처리한다(COMPLETED=종료 기록 있음·ACTIVE=미종료·미만료). 조회는 OPERATOR-only GET /api/v1/admin/audit/impersonation(FE /audit/impersonation). 세션 내 세부 액션 타임라인은 HQ 모드(Surface 11) 이벤트 emit 후속 — 본 기록은 세션 메타까지다. (→ features/audit/impersonation)

passwordMustChange enforcement (SPEC #022)

발급 계정(HQ_MANAGER·STORE_MANAGER)은 생성 시 임시 비번 + passwordMustChange=true 로 만들어진다. 임시 비번 사용자는 비밀번호를 변경하기 전까지 보호 영역·보호 API 에 접근할 수 없다. 강제는 이중으로 건다:

  1. FE layout redirect (주 경로)(protected)/layout.tsx(OPERATOR) · app/admin/layout.tsx(HQ_MANAGER) 의 server component 가 me.passwordMustChange === true/onboarding/change-password 로 redirect. 해당 레이아웃 내에서 role 분기보다 우선한다. 사용자는 화면 진입 전에 차단된다. STORE_MANAGER 는 현재 apps/admin 로그인이 차단(점장 모드 Surface 12 미구축 — §로그인 후 role 라우팅 참조)되어 이 FE redirect 에 도달하지 않으므로, STORE_MANAGER 의 강제는 현시점 아래 BE filter 단독이고 FE redirect 는 점장 클라이언트 도입 시 적용된다.
  2. BE 403 enforcement (직접 API 우회 방어, 이중)PasswordChangeEnforcementFilter (JWT 인증 직후) 가 pmc=true 인 인증 계정이 allowlist 밖 보호 endpoint 를 호출하면 403 PASSWORD_CHANGE_REQUIRED 를 반환. FE redirect 를 우회한 직접 API 호출까지 막는 defense-in-depth.

allowlist (filter 가 통과시키는 경로): POST /api/v1/auth/change-password · GET /api/v1/auth/me · POST /api/v1/auth/logout · POST /api/v1/auth/refresh (+ OPTIONS preflight). login·actuator·약관/개인정보 active 조회 등 unauthenticated 경로는 애초에 filter 대상이 아니다 (인증 필요 X).

판정 출처는 access token claim pmc 뿐 — 요청당 DB 조회 0. 발급 시점(login/refresh/change-password)의 현재값을 담으며, 15분 access TTL 만료로 stale claim 이 자연 해소된다.

change-password 성공 시: backend 가 passwordMustChange=false UPDATE + 해당 계정 refresh token 전부 삭제 + 새 access/refresh 발급(pmc=false). 다음 호출부터 enforcement 해제.

임퍼소네이션 무영향: 임퍼소네이션 토큰은 pmc=false 강제 발급 → 임퍼소네이션 세션은 enforcement 대상이 아니다 (운영사가 본사 계정으로 진입해도 차단되지 않음).

마이그레이션·신규 env var 없음 (v0.15.0).

CurrentPrincipal · PrincipalScopeGuard (SPEC #049)

본인 조회·테넌트 스코핑은 현재 주체 추출(CurrentPrincipal) → DB 재검증(PrincipalScopeGuard) → 검증된 값으로만 스코핑 의 3 단계로 일관 처리한다. role 별 인가 흐름 다이어그램은 Auth Flow 참조.

CurrentPrincipal — 현재 주체 추출

JwtAuthenticationFilter 가 검증한 AccessTokenClaims 를 평탄화한 값(application 레이어 공용).

data class CurrentPrincipal(
    val accountId: UUID,      // 인증 주체 계정 id (JWT subject)
    val role: Role,           // 토큰 role claim
    val hqId: UUID?,          // HQ_MANAGER 의 소속 본사 id (그 외 role 은 null — tenancy invariant)
    val storeId: UUID?,       // STORE_MANAGER 의 소속 매장 id (그 외 role 은 null)
    val impersonatedBy: UUID?, // 임퍼소네이션 토큰이면 원본 OPERATOR id (SPEC #005)
)

currentPrincipal()SecurityContextHolderauthentication.details(filter 가 적재한 AccessTokenClaims)에서 추출한다 — me/스코핑 endpoint 는 모두 인증 필수라 정상 흐름에선 claims 가 항상 존재한다(없으면 비인증으로 보고 차단). accountId 만 필요하면 currentAccountId()(구 ticket currentOperatorId() 흡수, 동작 불변).

claim 은 진실의 단일 소스가 아니다(backend.md #11). CurrentPrincipalhqId/storeId 를 그대로 조회 키로 쓰지 않는다 — 반드시 아래 가드로 DB 재검증한 값을 쓴다.

PrincipalScopeGuard — claim↔DB 재검증 (테넌트 스코핑)

verify(principal)accountIdOperatorAccount 를 재조회해 다음을 검증하고, 어긋나면 403 PRINCIPAL_SCOPE_MISMATCH(PrincipalScopeMismatchException):

  1. 미존재 → 403.
  2. status == ACTIVE (SUSPENDED·WITHDRAWN → 403 — 토큰 발급 후 정지·회수된 계정 우회 차단).
  3. 토큰 role == DB role.
  4. 토큰 hqId·storeId == DB 컬럼 (정확히 일치 — 타 테넌트 escalation 차단).

본인 스코핑 헬퍼는 검증된 DB 값을 반환한다:

  • verifyHqScope(principal) → DB hqId (role≠HQ_MANAGER 또는 hqId=null 이면 403). getHqMe 가 사용.
  • verifyStoreScope(principal) → DB storeId (role≠STORE_MANAGER 또는 storeId=null 이면 403). getStoreMe 가 사용.

불일치 상세(role/hqId/storeId 어디가 어긋났는지)는 응답에 노출하지 않고 INFO 로그로만 남긴다(응답은 고정 문구 — backend.md #10). 이로써 토큰 위변조·stale claim·정지 우회·타 테넌트 접근이 모두 한 곳에서 차단되며, 본인 조회는 항상 DB 가 인정한 스코프(hqId/storeId)로만 수행된다.

Refresh Flow

Endpoints

MethodPathAuthBodyResponse
POST/api/v1/auth/loginpublicLoginRequest { email, password }200 + AuthResponse
POST/api/v1/auth/refreshpublicRefreshRequest { refreshToken }200 + AuthResponse (rotation)
POST/api/v1/auth/logoutpublic (refreshToken 식별)LogoutRequest { refreshToken }200 (no body)
GET/api/v1/auth/meauthenticated200 + MeResponse
POST/api/v1/auth/change-passwordauthenticatedChangePasswordRequest { currentPassword, newPassword }200 + AuthResponse (세션 재봉인)
POST/api/v1/auth/impersonate-exchangepublicImpersonationExchangeRequest { exchangeToken }200 + ImpersonationExchangeResponse (SPEC #005)

Constraints

  • JWT 는 HttpOnly Cookie 만. UI 에서 토큰 직접 다루지 않음 (①).
  • Authorization: Bearer <access> 헤더는 BFF → backend 만. 브라우저 → BFF 는 cookie.
  • access 만료 시 backend 5xx 는 session 유지 (BACKEND_ERROR). 401 만 세션 무효화.

Roadmap

  • AccountStatus.SUSPENDED 즉시 세션 무효화 (session.destroy() 트리거) — 후속 SPEC
  • 비밀번호 변경 강제 정책 확정 ✅ SPEC #022 완료 — FE layout redirect + BE 403 PASSWORD_CHANGE_REQUIRED filter 이중 강제 (§passwordMustChange enforcement)
  • 60분 비활동 자동 로그아웃 (현재 refresh TTL 의존)
  • all-device logout (현재는 해당 token 만 revoke)

References

  • SPEC #003 §2-1~§2-10
  • SPEC #007 (비밀번호 변경) · SPEC #022 (passwordMustChange BE enforcement)
  • linkmusic-msa-space-was/.../OperatorAccount.kt
  • linkmusic-msa-space-was/.../JwtTokenService.kt
  • linkmusic-msa-space-was/.../config/SecurityConfig.kt
  • linkmusic-msa-space-was/.../PasswordChangeEnforcementFilter.kt (#022)
  • linkmusic-msa-space-was/.../application/auth/CurrentPrincipal.kt · PrincipalScopeGuard.kt (#049)
  • linkmusic-msa-space-was/.../api/hq/HqMeController.kt · api/store/StoreMeController.kt (#049)
  • linkmusic-frontend-space/apps/admin/src/lib/session.ts
  • linkmusic-frontend-space/apps/admin/src/lib/refresh.ts