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
| 컬럼 | 타입 | 비고 |
|---|---|---|
| id | UUID | PK (BaseEntity Style.TIME) |
| String | UNIQUE NOT NULL | |
| passwordHash | String | NOT NULL · BCrypt strength=12 (60자) |
| name | String? | nullable |
| role | Role | OPERATOR / HQ_MANAGER / STORE_MANAGER |
| hqId | UUID? | HQ_MANAGER 필수 · 그 외 NULL |
| storeId | UUID? | STORE_MANAGER 필수 · 그 외 NULL |
| status | AccountStatus | ACTIVE / SUSPENDED / WITHDRAWN |
| passwordMustChange | Boolean | default false. 발급 계정(HQ_MANAGER·STORE_MANAGER)의 임시 비번 → true. BE 403 filter(전 role) + FE redirect(현재 로그인 지원 role = OPERATOR·HQ_MANAGER) 이중 강제 (#022) — 아래 §passwordMustChange enforcement |
| lastLoginAt | Instant? | login 성공 시 업데이트 |
| createdAt/updatedAt/deletedAt/createdBy/updatedBy | BaseEntity |
도메인 invariant
role = HQ_MANAGER→hqId NOT NULL,storeId NULLrole = STORE_MANAGER→storeId NOT NULL,hqId NULL(storeId 의 hqId 가 묶음 본사)role = OPERATOR→hqId 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
| 컬럼 | 타입 | 비고 |
|---|---|---|
| id | UUID | BaseEntity (deletedAt 미사용) |
| accountId | UUID | FK → operator_account.id |
| tokenHash | String | SHA-256 hex (raw token 은 client 1회 전달) |
| expiresAt | Instant | 발급 + TTL |
| revokedAt | Instant? | 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 | 로그인 후 목적지 | 비고 |
|---|---|---|
| OPERATOR | safeNext(next) (기본 /) — ops 셸 | 기존 동작 |
| HQ_MANAGER | /admin — 본사 모드 셸 (배너 없음) | MeResponse.hqName 으로 본사명 표시 |
| STORE_MANAGER | redirect 없음 — “지원되지 않는 역할” 안내 + 세션 destroy | /api/auth/logout 호출로 cookie 정리. 앱 접근 차단 |
passwordMustChange=true 는 role 분기보다 우선한다(임시 비번 사용자는 role 무관하게
/onboarding/change-password 로 강제). 변경 완료 후 레이아웃 가드가 role home 으로 바운스한다.
교차 리디렉션 (양방향)
- ops 셸
(protected)/layout.tsx:me.role===HQ_MANAGER→redirect("/admin"). - 본사 모드
app/admin/layout.tsx:lm_sessionrole===OPERATOR(임퍼소네이션 아님) →redirect("/").
/admin 이중 세션
/admin/* 본사 모드 셸은 두 진입 모드를 모두 해석한다 (우선순위: 임퍼소네이션 우선).
| 모드 | 세션 cookie | 배너 | 세션 관리 | hqName 소스 |
|---|---|---|---|---|
| 임퍼소네이션 | lm_impersonation_session | O (빨강) | v1 고정 60분 카운트다운 (#015) | sealed 세션 |
| 실 HQ_MANAGER | lm_session role=HQ_MANAGER | X | ops 와 동일 refresh-aware | MeResponse.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 에 접근할 수 없다. 강제는 이중으로 건다:
- 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 는 점장 클라이언트 도입 시 적용된다. - BE 403 enforcement (직접 API 우회 방어, 이중) —
PasswordChangeEnforcementFilter(JWT 인증 직후) 가pmc=true인 인증 계정이 allowlist 밖 보호 endpoint 를 호출하면 403PASSWORD_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() 이 SecurityContextHolder 의 authentication.details(filter 가 적재한
AccessTokenClaims)에서 추출한다 — me/스코핑 endpoint 는 모두 인증 필수라 정상 흐름에선 claims 가 항상
존재한다(없으면 비인증으로 보고 차단). accountId 만 필요하면 currentAccountId()(구 ticket
currentOperatorId() 흡수, 동작 불변).
claim 은 진실의 단일 소스가 아니다(backend.md #11).
CurrentPrincipal의hqId/storeId를 그대로 조회 키로 쓰지 않는다 — 반드시 아래 가드로 DB 재검증한 값을 쓴다.
PrincipalScopeGuard — claim↔DB 재검증 (테넌트 스코핑)
verify(principal) 가 accountId 로 OperatorAccount 를 재조회해 다음을 검증하고, 어긋나면 403
PRINCIPAL_SCOPE_MISMATCH(PrincipalScopeMismatchException):
- 미존재 → 403.
status == ACTIVE(SUSPENDED·WITHDRAWN → 403 — 토큰 발급 후 정지·회수된 계정 우회 차단).- 토큰
role== DB role. - 토큰
hqId·storeId== DB 컬럼 (정확히 일치 — 타 테넌트 escalation 차단).
본인 스코핑 헬퍼는 검증된 DB 값을 반환한다:
verifyHqScope(principal)→ DBhqId(role≠HQ_MANAGER 또는 hqId=null 이면 403).getHqMe가 사용.verifyStoreScope(principal)→ DBstoreId(role≠STORE_MANAGER 또는 storeId=null 이면 403).getStoreMe가 사용.
불일치 상세(role/hqId/storeId 어디가 어긋났는지)는 응답에 노출하지 않고 INFO 로그로만 남긴다(응답은
고정 문구 — backend.md #10). 이로써 토큰 위변조·stale claim·정지 우회·타 테넌트 접근이 모두 한 곳에서
차단되며, 본인 조회는 항상 DB 가 인정한 스코프(hqId/storeId)로만 수행된다.
Refresh Flow
Endpoints
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/v1/auth/login | public | LoginRequest { email, password } | 200 + AuthResponse |
| POST | /api/v1/auth/refresh | public | RefreshRequest { refreshToken } | 200 + AuthResponse (rotation) |
| POST | /api/v1/auth/logout | public (refreshToken 식별) | LogoutRequest { refreshToken } | 200 (no body) |
| GET | /api/v1/auth/me | authenticated | — | 200 + MeResponse |
| POST | /api/v1/auth/change-password | authenticated | ChangePasswordRequest { currentPassword, newPassword } | 200 + AuthResponse (세션 재봉인) |
| POST | /api/v1/auth/impersonate-exchange | public | ImpersonationExchangeRequest { 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 403PASSWORD_CHANGE_REQUIREDfilter 이중 강제 (§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.ktlinkmusic-msa-space-was/.../JwtTokenService.ktlinkmusic-msa-space-was/.../config/SecurityConfig.ktlinkmusic-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.tslinkmusic-frontend-space/apps/admin/src/lib/refresh.ts