Skip to main content

Auth Service

This is the reference page for lumie-backend/modules/auth, the tenant-aware authentication module that owns registration, session issuance, JWT validation, profile management, and password-based sign-in.

Source Paths

PathRole
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/web/AuthController.javaPublic HTTP surface
lumie-backend/modules/auth/src/main/java/com/lumie/auth/application/service/{AuthRegistrationService,AuthSessionService,AuthQueryService,UserProfileService}.javaMain registration, login, refresh, session, and profile use cases
lumie-backend/modules/auth/src/main/java/com/lumie/auth/application/service/LoginSessionHelper.javaShared token issuance, Redis session persistence, and single-device-category enforcement
lumie-backend/modules/auth/src/main/java/com/lumie/auth/domain/vo/SessionRevocationReason.javaSession revocation reason values used when refresh failures need user-facing semantics
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/web/CookieUtils.javaShared auth-cookie builder for access and refresh session cookies
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/security/JwtAuthenticationFilter.javaJWT extraction from bearer tokens or lumie_access_token cookie
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/out/persistence/RedisTokenRepository.javaRefresh-token, blacklist, and session storage in Redis
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/internal/AuthServiceAdapter.javaPublished in-process auth contract for other modules
lumie-backend/modules/auth/src/main/java/com/lumie/auth/domain/entity/User.javaTenant-scoped users entity
lumie-backend/app/src/main/java/com/lumie/app/config/{SecurityConfig,internal/InternalHmacAuthFilter}.javaApp-level security rules and internal HMAC protection that the auth module participates in
lumie-backend/app/src/main/resources/{application.yaml,application-dev.yml}Cookie defaults and dev-only SameSite overrides
lumie-backend/app/src/main/resources/db/migration/public/{V2__create_users_table,V4__federated_identities,V12__slim_users_to_owner_directory,V18__rls_baseline,V22__introduce_owner_directory}.sqlCore auth-facing schema evolution for tenant users, root-entry owner lookup, and historical federated-login artifacts that may still exist in upgraded databases

Public Surface

EndpointPurpose
POST /v1/registerStudent self-registration inside an existing tenant
POST /v1/register/ownerOwner registration plus brand-new tenant creation
POST /v1/login, POST /v1/refresh, POST /v1/logout, POST /v1/logout-allSession lifecycle
GET /v1/me, PATCH /v1/me, POST /v1/me/password, PATCH /v1/me/avatarProfile, password, and avatar management
GET /v1/me/sessions, DELETE /v1/me/sessions/{sid}, DELETE /v1/me/sessionsSession inspection and revocation

The controller sets lumie_access_token and lumie_refresh_token cookies on successful registration, login, and refresh.

No GET /v1/oauth2/{provider}/... endpoints remain in AuthController, and the supported runtime contract no longer includes Kakao, Google, or Naver sign-in.

Internal Surface And Dependencies

SurfaceRole
lumie-backend/libs/internal-api/src/main/java/com/lumie/auth/api/AuthService.javaIn-process contract for token validation, user lookup, user creation, password reset, login-ID change, and avatar seed lookup
lumie-backend/libs/internal-api/src/main/java/com/lumie/auth/api/OwnerRegisteredEvent.javaAFTER_COMMIT event that downstream modules use to bootstrap OWNER-linked records
TenantServiceUsed both for registration-time tenant creation and for login-time tenant validation
Redis token/session storeRefresh tokens, token blacklist entries, and per-user session metadata are stored outside PostgreSQL

Aggregate And State

Entity or storeNotes
UserTenant-scoped user row under RLS
AuthTokenRefresh-token value object persisted in Redis rather than JPA
Redis sessionsJSON session metadata keyed by tenant and session ID, including device category and token JTIs
Redis revocation reasonsauth:session-revoked:{tenantSlug}:{sid} records why a missing refresh token was revoked while the reason remains useful to the browser
owner_directoryRoot-entry lookup table used to resolve an OWNER's tenant before a tenant context already exists

Historical Schema Artifacts

Applied databases may still carry legacy federated-login artifacts from earlier migrations, but the supported runtime auth contract no longer depends on them:

  • federated_identities may still exist from V4__federated_identities.sql
  • users.oauth_provider appears in the early users-table migrations such as V2__create_users_table.sql and V18__rls_baseline.sql

The runtime no longer reads or writes either artifact. Leave them untouched during the code-removal deploy so already-upgraded databases can keep serving the password-login, refresh, and profile flows without a same-release contract change.

After deploy verification confirms the password-based auth paths are healthy, a later forward-only contract migration can archive or drop the unused table and column. Do not treat untracked cleanup drafts as active migrations until that follow-up is actually authored and landed.

Runtime Flow

Contract Notes

The login path truly supports two entry styles: portal-entry requests with an existing tenant context, and root-entry owner logins that discover tenant context first.

The published internal API also has one notable field mismatch today.

Source anchor: lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/internal/AuthServiceAdapter.java, AuthServiceAdapter#getUserInfo.

// lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/internal/AuthServiceAdapter.java
return Optional.of(new UserData(
user.id(), user.userLoginId(), user.name(),
user.role().name(), claims.tenantSlug(), claims.tenantId()
));

AuthService.UserData names its second field email, but AuthServiceAdapter currently fills it with userLoginId. Callers should treat that field as login identity, not guaranteed email, until the interface is corrected.

Example Contracts

These examples come directly from AuthController, LoginRequest, AuthResponse, and UserResponse.

Login

POST /v1/login
Content-Type: application/json

{
"userLoginId": "alice_owner",
"password": "SecretPass123!",
"customId": "acme"
}
HTTP/1.1 200 OK
Set-Cookie: lumie_access_token=<jwt>; HttpOnly; ...
Set-Cookie: lumie_refresh_token=<jwt>; HttpOnly; ...

{
"accessExpiresIn": <seconds>,
"refreshExpiresIn": <seconds>,
"user": {
"id": 42,
"userLoginId": "alice_owner",
"name": "Alice",
"phone": "01000000000",
"email": "alice@example.com",
"role": "OWNER",
"tenantSlug": "acme",
"tenantId": 7,
"tenantCustomId": "acme",
"tenantCustomDomain": "academy.example.com",
"tenantOnboardingCompletedAt": null,
"avatarSeed": "seed-1"
}
}

The body never carries JWTs. AuthController.buildAuthResponse(...) writes both tokens to cookies and returns only expiry metadata plus user.

UserResponse includes tenantCustomId and tenantCustomDomain so the frontend can preserve the correct tenant login surface during auth-boundary redirects even when the current tenant query cache is cold. Those fields are populated by AuthSessionService, AuthQueryService, and UserProfileService from TenantService.TenantData, not from browser input.

Refresh

POST /v1/refresh is cookie-only. AuthController.refresh(...) ignores any JSON body and reads lumie_refresh_token from the cookie.

POST /v1/refresh
Cookie: lumie_refresh_token=<refresh-jwt>
HTTP/1.1 200 OK
Set-Cookie: lumie_access_token=<rotated-jwt>; HttpOnly; ...
Set-Cookie: lumie_refresh_token=<rotated-jwt>; HttpOnly; ...

{
"accessExpiresIn": <seconds>,
"refreshExpiresIn": <seconds>,
"user": null
}

When a previous session was replaced by a new login in the same device category, the old browser's next refresh attempt returns a ProblemDetail response with code: "AUTH_017". Source anchors: AuthSessionService.refresh(...), LoginSessionHelper.enforceSessionLimitByCategory(...), and RedisTokenRepository.findSessionRevocationReason(...).

HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json

{
"type": "urn:lumie:error:auth-017",
"title": "Session replaced by another login",
"status": 401,
"detail": "Session replaced by another login",
"code": "AUTH_017"
}

Failure And Runtime Behavior

  • JwtAuthenticationFilter accepts either Authorization: Bearer ... or the lumie_access_token cookie.
  • refresh(...) rejects blacklisted tokens and rotates both access and refresh tokens together.
  • LoginSessionHelper enforces one active session per device category by revoking prior sessions in the same category before issuing new tokens.
  • When that category enforcement replaces an existing session, LoginSessionHelper stores SESSION_REPLACED_BY_LOGIN in Redis before deleting the session metadata. AuthSessionService.refresh(...) maps that reason back to AUTH_017 when the old browser presents the now-missing refresh token.
  • AuthSessionService.resolveLoginTenant(...) rejects inactive tenants for both root-entry and portal-entry logins.
  • Owner registration publishes OwnerRegisteredEvent after the OWNER user and tenant are created, breaking the old auth-to-staff synchronous dependency.
  • /internal/** routes are protected by InternalHmacAuthFilter, not by end-user JWTs.

Verification

cd lumie-backend
./gradlew :modules:auth:test
./gradlew :modules:auth:test --tests '*AuthControllerMeTest'
./gradlew :modules:tenant:test :modules:notification:test

Expected success signals:

  • Gradle exits with BUILD SUCCESSFUL, and the auth module tests still cover the password-based registration, login, refresh, and /v1/me flows.
  • AuthControllerMeTest still proves that the controller resolves the authenticated user profile, emits the tenant URL fields on UserResponse, and maps replaced-session refresh failures to AUTH_017.
  • The tenant and notification module tests prove the updated TenantService.TenantData internal contract still compiles across modules that consume tenant metadata.