본문으로 건너뛰기

인증 서비스

이 페이지는 lumie-backend/modules/auth를 위한 레퍼런스 페이지입니다. 이 모듈은 등록, 세션 발급, JWT 검증, 프로필 관리, 비밀번호 기반 로그인을 담당하는 테넌트-aware 인증 모듈입니다.

소스 경로

경로역할
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/web/AuthController.java공개 HTTP 인터페이스
lumie-backend/modules/auth/src/main/java/com/lumie/auth/application/service/{AuthRegistrationService,AuthSessionService,AuthQueryService,UserProfileService}.java등록, 로그인, 갱신, 세션, 프로필의 주요 유스케이스
lumie-backend/modules/auth/src/main/java/com/lumie/auth/application/service/LoginSessionHelper.java공통 토큰 발급, Redis 세션 저장, 디바이스 카테고리별 단일 세션 강제
lumie-backend/modules/auth/src/main/java/com/lumie/auth/domain/vo/SessionRevocationReason.javarefresh 실패에 사용자에게 보여줄 의미가 필요할 때 사용하는 세션 revoke 사유 값
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/web/CookieUtils.javaaccess/refresh 세션 cookie를 만드는 공통 auth 쿠키 생성기
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/security/JwtAuthenticationFilter.javabearer 토큰 또는 lumie_access_token cookie에서 JWT 추출
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/out/persistence/RedisTokenRepository.javaRedis에 refresh 토큰, blacklist, 세션 저장
lumie-backend/modules/auth/src/main/java/com/lumie/auth/adapter/in/internal/AuthServiceAdapter.java다른 모듈에 공개되는 프로세스 내부 auth 계약
lumie-backend/modules/auth/src/main/java/com/lumie/auth/domain/entity/User.java테넌트 범위 users 엔티티
lumie-backend/app/src/main/java/com/lumie/app/config/{SecurityConfig,internal/InternalHmacAuthFilter}.javaauth 모듈이 참여하는 앱 수준 security 규칙과 내부 HMAC 보호
lumie-backend/app/src/main/resources/{application.yaml,application-dev.yml}쿠키 기본값과 dev 전용 SameSite 재정의
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}.sql테넌트 user, root-entry OWNER 조회, 그리고 업그레이드된 데이터베이스에 남아 있을 수 있는 과거 federated-login 관련 스키마 변경

공개 인터페이스

엔드포인트목적
POST /v1/register기존 테넌트 내부의 student self-등록
POST /v1/register/ownerOWNER 등록과 완전히 새로운 테넌트 생성
POST /v1/login, POST /v1/refresh, POST /v1/logout, POST /v1/logout-all세션 수명주기
GET /v1/me, PATCH /v1/me, POST /v1/me/password, PATCH /v1/me/avatar프로필, 비밀번호, avatar 관리
GET /v1/me/sessions, DELETE /v1/me/sessions/{sid}, DELETE /v1/me/sessions세션 조회와 revoke

컨트롤러는 등록, 로그인, refresh 성공 시 lumie_access_tokenlumie_refresh_token cookie를 설정합니다.

AuthController에는 더 이상 GET /v1/oauth2/{provider}/... 엔드포인트가 없으며, 지원되는 런타임 계약에는 Kakao, Google, Naver 로그인이 포함되지 않습니다.

내부 인터페이스와 의존성

인터페이스역할
lumie-backend/libs/internal-api/src/main/java/com/lumie/auth/api/AuthService.java토큰 검증, user 조회, user 생성, 비밀번호 재설정, 로그인 ID 변경, avatar seed 조회를 위한 프로세스 내부 계약
lumie-backend/libs/internal-api/src/main/java/com/lumie/auth/api/OwnerRegisteredEvent.javadownstream 모듈이 OWNER 연결 레코드를 부트스트랩하는 데 쓰는 AFTER_COMMIT event
TenantService등록 시 테넌트 생성과 로그인 시 테넌트 검증에 모두 사용
Redis 토큰/세션 storerefresh 토큰, 토큰 blacklist 항목, 사용자별 세션 metadata를 PostgreSQL 밖에 저장

집계와 상태

엔티티 또는 저장소참고
UserRLS 아래의 테넌트 범위 user 행
AuthTokenJPA가 아니라 Redis에 저장되는 refresh-토큰 value object
Redis 세션tenant와 세션 ID를 키로 하고 device category와 토큰 JTI를 포함하는 JSON 세션 메타데이터
Redis revocation reasonsauth:session-revoked:{tenantSlug}:{sid}는 브라우저에 아직 의미 있는 revoke 사유가 필요할 때 missing refresh token의 사유를 기록합니다
owner_directory테넌트 컨텍스트가 아직 없을 때 OWNER의 tenant를 해석하는 루트 진입 조회 테이블

과거 스키마 아티팩트

적용이 끝난 데이터베이스에는 예전 federated-login 관련 아티팩트가 남아 있을 수 있지만, 현재 지원되는 런타임 인증 계약은 이에 의존하지 않습니다.

  • federated_identitiesV4__federated_identities.sql로 생성된 과거 federated-login 테이블이라 남아 있을 수 있습니다
  • users.oauth_providerV2__create_users_table.sql, V18__rls_baseline.sql 같은 초기 users-table migration에 등장합니다

런타임은 이 두 아티팩트를 더 이상 읽지도 쓰지도 않습니다. 이미 업그레이드된 데이터베이스가 같은 릴리스에서 계약 변경 없이도 비밀번호 로그인, refresh, 프로필 플로우를 계속 제공할 수 있도록, 코드 제거 배포 중에는 그대로 유지해야 합니다.

배포 검증에서 비밀번호 기반 인증 경로가 정상임이 확인된 뒤, 나중의 forward-only contract migration에서 사용하지 않는 테이블과 컬럼을 보관 또는 삭제할 수 있습니다. 후속 정리안이 실제로 작성되어 반영되기 전까지는 추적되지 않은 cleanup 초안을 활성 migration으로 간주하지 마세요.

런타임 흐름

계약 참고 사항

로그인 경로는 실제로 두 가지 진입 방식을 지원합니다. 기존 테넌트 컨텍스트가 있는 포털 진입 요청과, 먼저 테넌트 컨텍스트를 찾아야 하는 root-entry OWNER 로그인입니다.

공개된 내부 API에는 현재 눈에 띄는 필드 불일치도 하나 있습니다.

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는 두 번째 필드 이름을 email로 정의하지만, AuthServiceAdapter는 현재 그 자리에 userLoginId를 채웁니다. 인터페이스가 수정되기 전까지 호출자는 이 필드를 이메일로 단정하지 말고 로그인 식별자로 취급해야 합니다.

예시 계약

이 예시는 AuthController, LoginRequest, AuthResponse, UserResponse에서 직접 가져왔습니다.

로그인

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"
}
}

본문에는 JWT가 들어가지 않습니다. AuthController.buildAuthResponse(...)가 두 token을 모두 cookie에 기록하고, 응답 본문에는 만료 정보와 user만 돌려줍니다.

UserResponsetenantCustomIdtenantCustomDomain을 포함합니다. 이 값은 현재 테넌트 쿼리 캐시가 비어 있어도 프론트엔드가 auth boundary redirect에서 올바른 테넌트 로그인 표면을 유지하기 위한 값입니다. 이 필드들은 브라우저 입력이 아니라 TenantService.TenantData에서 온 값을 AuthSessionService, AuthQueryService, UserProfileService가 채웁니다.

토큰 갱신

POST /v1/refresh는 쿠키 전용입니다. AuthController.refresh(...)는 JSON body를 무시하고 cookie에서 lumie_refresh_token을 읽습니다.

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
}

같은 디바이스 카테고리에서 새 로그인이 발생해 이전 세션이 교체되면, 기존 브라우저의 다음 refresh 시도는 code: "AUTH_017"을 가진 ProblemDetail 응답을 받습니다. 소스 앵커는 AuthSessionService.refresh(...), LoginSessionHelper.enforceSessionLimitByCategory(...), 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"
}

실패와 런타임 동작

  • JwtAuthenticationFilterAuthorization: Bearer ... 또는 lumie_access_token cookie를 모두 받아들입니다.
  • refresh(...)는 blacklist 처리된 token을 거부하고 access/refresh token을 함께 회전시킵니다.
  • LoginSessionHelper는 디바이스 카테고리별로 이전 세션을 revoke해 같은 카테고리에는 하나의 활성 세션만 남깁니다.
  • 이 카테고리 강제가 기존 세션을 교체하면 LoginSessionHelper는 세션 metadata를 삭제하기 전에 Redis에 SESSION_REPLACED_BY_LOGIN을 저장합니다. 기존 브라우저가 삭제된 refresh token을 제시하면 AuthSessionService.refresh(...)가 이 사유를 AUTH_017로 다시 매핑합니다.
  • AuthSessionService.resolveLoginTenant(...)는 root-entry와 portal-entry 로그인 모두에서 비활성 tenant를 거부합니다.
  • OWNER 등록은 OWNER user와 테넌트 생성 이후 OwnerRegisteredEvent를 발행해 예전의 auth-to-staff 동기 의존성을 끊습니다.
  • /internal/** route는 최종 사용자 JWT가 아니라 InternalHmacAuthFilter로 보호됩니다.

검증

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

예상 성공 신호:

  • Gradle이 BUILD SUCCESSFUL로 종료되고, auth 모듈 테스트가 비밀번호 기반 등록, 로그인, refresh, /v1/me 흐름을 계속 검증합니다.
  • AuthControllerMeTest가 인증된 사용자 프로필, UserResponse의 테넌트 URL 필드, 교체된 세션 refresh 실패의 AUTH_017 매핑을 검증합니다.
  • tenant와 notification 모듈 테스트는 갱신된 TenantService.TenantData 내부 계약이 테넌트 metadata를 소비하는 모듈 전반에서 계속 컴파일됨을 검증합니다.

관련 페이지