Auth Model (OperatorAccount · RefreshToken · JWT)
SPEC #003 정합 (+ #022 enforcement · #049 CurrentPrincipal·scope guard).
Overview
모든 사용자는 단일 entity OperatorAccount. role 로 권한 분기. JWT 는 access (30min, HS256) + refresh
(14d, opaque random, DB 저장 hash + rotation + grace).
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 · change-password 강제로그아웃은 row hard-delete — grace 부활 방지) |
| createdAt/updatedAt | (createdBy 없음 — 자기 자신) |
unique: (account_id, token_hash) 복합. index: (account_id, revoked_at).
AccountToken Entity (SPEC #145)
계정 설정·비밀번호 재설정·이메일 변경의 링크 토큰. 원문 토큰은 메일 URL(?token=)에만 담기고
DB 엔 해시(tokenHash)만 저장한다. 일회용(usedAt 가드)·만료·같은 type 재발급 시 이전 미사용
토큰 무효화.
| 컬럼 | 타입 | 비고 |
|---|---|---|
| id | UUID | |
| accountId | UUID | 대상 계정 |
| type | enum | ACCOUNT_SETUP(72h) · PASSWORD_RESET(1h) · EMAIL_CHANGE(1h) |
| tokenHash | String | 원문 미저장(URL 에만) |
| newEmail | String? | EMAIL_CHANGE 전용 — 전환할 새 이메일 |
| expiresAt | Instant | type 별 TTL |
| usedAt | Instant? | 소비 시 채움(일회용 가드) |
| createdAt |
무효/만료/사용됨 토큰은 클라이언트에 ACCOUNT_TOKEN_INVALID 로 응답한다. 비밀번호 재설정 요청은
계정 존재 여부와 무관하게 동일 응답(존재 은닉). 이메일 변경 전환 시 새 이메일 중복은
EMAIL_ALREADY_IN_USE. 자세한 흐름은 Account & Auth Emails.
JWT 상세
- Library:
io.jsonwebtoken:jjwt-api:0.12.6+jjwt-impl+jjwt-jackson - 서명: HS256 + 단일 시크릿
JWT_SECRET(env, ≥256-bit / 64자 권장) - Access token:
- 수명: 30분
- 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). 30분 access TTL 만료로 stale claim 자연 해소. 임퍼소네이션 토큰은pmc=false강제
- Refresh token:
- 형식: opaque random 32 bytes base64url (~43자)
- DB 저장: SHA-256 hex
- 수명: 14일 (모든 role) · 임퍼소네이션은 60분
- rotation: refresh 호출 시 prev
revokedAt = now, 새 token 발급 (1회용) - grace period 60초(
app.jwt.refresh-grace): revoked 토큰이라도 grace 내 + 만료 전이면 멀티탭 동시 refresh race 로 간주 → re-revoke 없이 새 token 재발급(impersonatedBy 분기 보존). grace 밖 revoked 재사용은 401. 방어 순서 = 계정/HQ/Store 상태검증 → 만료 → grace (정지 계정·만료 토큰은 grace 로도 차단). FE middleware proactive refresh 와 짝. - logout · change-password: soft-revoke 가 아니라 row hard-delete(grace 60초 내 부활 방지). 이후 재사용은
AUTH_INVALID_REFRESH_TOKEN(row 없음).
비밀번호 해싱
BCryptPasswordEncoder(strength = 12)(Spring Security 표준)- Argon2id 는 v1.1+ 검토
Authentication Flow
로그인 후 role 라우팅 (SPEC #016 · #150)
로그인 성공 응답(AuthResponse.role)으로 클라이언트가 분기한다. 각 레이아웃 가드가
defense-in-depth 로 같은 규칙을 server-side 에서 한 번 더 강제한다.
운영사 백오피스(apps/admin)는 OPERATOR 전용이다(SPEC #150). 과거의 본사 모드 placeholder
셸(/admin)은 제거됐고, 본사 기능 단일 소스는 apps/space(space.linkmusic.io)다.
| role | 로그인 후 목적지 (apps/admin) | 비고 |
|---|---|---|
| OPERATOR | safeNext(next) (기본 /) — ops 셸 | 기존 동작 |
| HQ_MANAGER | redirect 없음 — “본사는 space.linkmusic.io” 링크 안내 + 세션 destroy | NEXT_PUBLIC_SPACE_ORIGIN 절대 URL. 본사는 apps/space 단일 소스 (SPEC #150) |
| STORE_MANAGER | redirect 없음 — “운영사 직원 전용” 안내 + 세션 destroy | /api/auth/logout 호출로 cookie 정리. 앱 접근 차단 |
passwordMustChange=true 는 role 분기보다 우선한다(OPERATOR 의 임시 비번 사용자는
/onboarding/change-password 로 강제). apps/space 의 HQ_MANAGER·STORE_MANAGER role 분기는
Auth Flow §6 참조(본사 /admin·점장 /store).
Fail-closed 가드 (apps/admin)
- ops 셸
(protected)/layout.tsx: OPERATOR 외 전부/login으로 fail-closed (HQ_MANAGER·STORE_MANAGER·role 누락·구버전 세션 포함). 과거의me.role===HQ_MANAGER → /admin교차 리디렉션은 SPEC #150 에서 제거됨(admin 에 본사 셸이 없으므로). - 본사 모드 셸(
/admin)·이중 세션 해석은apps/space의 책임이다 — Auth Flow §4 참조. 운영사→본사 임퍼소네이션은apps/space로 핸드오프되며 SPEC #150 과 무관하게 유지된다.
임퍼소네이션 세션 감사 기록 (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 (주 경로) —
apps/admin (protected)/layout.tsx(OPERATOR) ·apps/space의 본사/점장 셸 layout 의 server component 가me.passwordMustChange === true면/onboarding/change-password로 redirect. 해당 레이아웃 내에서 role 분기보다 우선한다. 사용자는 화면 진입 전에 차단된다. HQ_MANAGER·STORE_MANAGER 는 apps/admin 로그인이 차단(운영사 앱 OPERATOR 전용 — SPEC #150·§로그인 후 role 라우팅 참조)되어 admin 의 FE redirect 에는 도달하지 않는다 — 본사·점장의 FE redirect 는apps/space셸이 담당하고, 직접 API 우회는 아래 BE filter 가 막는다. - 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)의 현재값을 담으며, 30분 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).
임퍼소네이션 민감 작업 차단 (SPEC #150)
임퍼소네이션 세션(impersonatedBy claim 보유)으로는 계정 주인 본인만 수행해야 하는 민감 작업을
할 수 없다. backend 가 토큰의 impersonatedBy 마커를 보고 해당 endpoint 를 403
IMPERSONATION_FORBIDDEN_ACTION 으로 차단한다.
| 차단 대상 작업 | endpoint | 사유 |
|---|---|---|
| 비밀번호 변경 | POST /api/v1/auth/change-password | 자격증명 자체 변경 — 본인만 |
| 이메일 변경(요청) | 이메일 변경 시작 endpoint | 본인 인증 메일 흐름 — 본인만 |
운영사가 본사 신원을 대신 사용하는 동안 그 계정의 로그인 자격(비밀번호·이메일)을 바꾸지 못하게 하는 안전장치다. 감사 가능성(임퍼소네이션 세션 기록)과 함께, 대리 진입이 계정 탈취로 이어지지 않도록 한다.
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