인증과 테넌시
인증과 테넌시는 의도적으로 결합되어 있습니다. Lumie에서는 대부분의 유의미한 백엔드 작업이 인증된 호출자와 PostgreSQL RLS에 바인딩할 수 있는 테넌트 ID를 둘 다 필요로 합니다. 테넌트 범위가 없는 신원 정보만으로는 일반적인 읽기와 쓰기를 수행할 수 없습니다.
이 페이지는 요청 컨텍스트 계약을 다루는 레퍼런스 문서입니다.
소스 경로
| 경로 | 역할 |
|---|---|
app/src/main/java/com/lumie/app/config/SecurityConfig.java | 전역 라우트 보호와 필터 순서 |
modules/auth/src/main/java/com/lumie/auth/adapter/in/security/JwtAuthenticationFilter.java | 최종 사용자 인증 필터 |
modules/auth/src/main/java/com/lumie/auth/adapter/out/security/JwtTokenProvider.java | JWT 클레임 형식과 발급 |
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/AuthController.java | 등록, 로그인, 갱신, 로그아웃, 프로필 엔드포인트 |
modules/auth/src/main/java/com/lumie/auth/adapter/in/web/CookieUtils.java | 공통 access/refresh 쿠키 계약 |
app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.java | /internal/** 인증 계약 |
app/src/main/resources/{application.yaml,application-dev.yml} | auth 쿠키 기본값과 dev 전용 SameSite 재정의 |
libs/common/src/main/java/com/lumie/common/tenant/RequestContextFilter.java | 요청 상관관계, MDC, 대체 헤더 채움 |
modules/tenant/src/main/java/com/lumie/tenant/adapter/in/web/TenantController.java | 익명 공개 테넌트 조회 |
modules/homepage/src/main/java/com/lumie/homepage/application/service/HomepageQueryService.java | 테넌트 해석 이후의 공개 홈페이지 조회 |
app/src/main/resources/db/migration/public/{V2__create_users_table,V4__federated_identities,V18__rls_baseline}.sql | 업그레이드된 데이터베이스에 남아 있을 수 있는 과거 auth/federated-login 스키마 아티팩트 |
요청 컨텍스트 흐름
최종 사용자 인증 계약
주요 HTTP 인터페이스
| 흐름 | 엔드포인트 | 비고 |
|---|---|---|
| 등록과 로그인 | POST /v1/register, POST /v1/register/owner, POST /v1/login | access 토큰, refresh 토큰, 사용자 payload를 발급 |
| 세션 수명주기 | POST /v1/refresh, POST /v1/logout, POST /v1/logout-all, GET/DELETE /v1/me/sessions... | refresh는 쿠키에서 refresh 토큰을 읽음 |
| 프로필 | GET/PATCH /v1/me, POST /v1/me/password, PATCH /v1/me/avatar | 인증된 사용자 컨텍스트 필요 |
AuthController는 GET /v1/oauth2/{provider}/... route를 노출하지 않습니다.
Kakao, Google, Naver 로그인은 지원되는 런타임 요청 컨텍스트 계약 밖에
있습니다.
JWT에 담기는 내용
JwtTokenProvider는 access 토큰과 refresh 토큰 모두에 다음과 같은 핵심
클레임을 담아 발급합니다.
| 클레임 | 의미 |
|---|---|
sub | 사용자 ID |
name | 표시 이름 |
tenant_slug | 제품 측면 테넌트 식별자 |
tenant_id | RLS에 사용되는 데이터베이스 측 테넌트 식별자 |
role | Role에서 가져오는 거친 수준의 역할 |
sid | access 토큰과 refresh 토큰 쌍이 공유하는 세션 식별자 |
jti | 토큰 식별자 |
type | access 또는 refresh |
JWT 필터는 다음 둘 중 하나를 받아들입니다.
Authorization: Bearer <token>lumie_access_token쿠키
성공 시 다음을 설정합니다.
SecurityContextHolder- slug와 ID를 모두 담은
TenantContextHolder - user ID, name, 역할, 세션 ID를 담은
UserContextHolder
쿠키 계약
CookieUtils는 다음을 발급합니다.
lumie_access_tokenlumie_refresh_token
application.yaml과 CookieConfig 기준 기본값:
HttpOnly=trueSecure=true- 기본
SameSite=Lax - dev 재정의:
application-dev.yml의cookie.sameSite=None
브라우저 세션 계약은 등록, 로그인, refresh, 인증된 프로필 조회 전반에서 동일합니다. 지원되는 런타임 플로우에는 제공자 전용 세션 저장소가 따로 없습니다.
내부 인증 계약
/internal/** route는 user JWT가 아니라 InternalHmacAuthFilter로
보호됩니다.
필수 header:
X-Tenant-SlugX-TimestampX-Signature
서명 공식:
Source anchor: lumie-backend/app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.java#computeSignature
HMAC-SHA256(timestamp + "\n" + tenantSlug + "\n" + body)
필터에서 강제하는 다른 규칙:
- 최대 timestamp skew:
300초 - 최대 buffered body size:
1 MiB - tenant는 존재하고 활성 상태여야 함
- 검증에 성공하면 synthetic
ROLE_INTERNAL을 부여
이 계약은 내부 chatbot callback과 워커 대상 내부 HTTP 인터페이스에서 사용됩니다.
공개 테넌트 확인 경로
일부 route는 주변 테넌트 컨텍스트 없이 시작하고 tenant를 먼저 발견합니다.
GET /v1/tenants/public/by-custom-id/{customId}GET /v1/tenants/public/by-domain?host=...GET /v1/homepage/public/by-custom-id/{customId}
homepage 경로가 가장 중요한 경계 예시입니다.
HomepageQueryService.getPublicByCustomId(...)가 테넌트 모듈에customId -> {slug, tenantId}조회를 요청합니다.TenantContextHolder.withinContext(...)로 두 값을 모두 복원합니다.- 트랜잭션 경계를 만들기 위해 프록시된 내부 빈
HomepageQueryService.Tx로 진입합니다. RlsTenantContextAspect가app.tenant_id를 바인딩하고, 그제야 read가 데이터베이스에서 보이게 됩니다.
테넌트 ID와 프록시된 트랜잭션이 없으면 홈페이지 행은 RLS 아래에서 계속 보이지 않습니다.
라우트 보호 요약
SecurityConfig는 다음과 같은 주요 비인증 인터페이스를 허용합니다.
- 등록, 로그인, refresh
actuator/**/v3/api-docs/**,/swagger-ui/**,/swagger-ui.html- 공개 테넌트, 홈페이지, 파일 라우트
/internal/**, 단 HMAC 필터가ROLE_INTERNAL을 부여한 이후에만 허용
그 외 모든 것은 인증된 사용자 컨텍스트가 필요합니다.
다른 모듈로 이어지는 흐름
- auth의
OwnerRegisteredEvent는 staff가 OWNER staff record를 부트스트랩하는 데 소비합니다. StudentSelfRegisteredEvent는 별도의 student self-등록 경로를 시작합니다.- OWNER 로그인과 refresh는 일반적인 요청 시점 테넌트 컨텍스트가 존재하기 전에 테넌트 상태를 해석할 수 있으며, 그래서 auth가 테넌트 조회 데이터에 의존합니다.
실패 모드와 드리프트
tenantSlug만 있고tenantId가 없으면 RLS가 적용되는 데이터에 접근할 수 없습니다.- 적용된 데이터베이스에는
federated_identities나 과거users.oauth_provider컬럼 같은 역사적 federated-login 스키마가 남아 있을 수 있습니다. 이 아티팩트는 지원되는 런타임 로그인 계약의 일부가 아니며, 활성 요청 컨텍스트 의존성으로 취급하면 안 됩니다. - 런타임은 이런 역사적 아티팩트를 더 이상 읽거나 쓰지 않습니다. 코드 제거 배포 중에는 그대로 두고, 비밀번호 기반 로그인과 refresh 경로가 정상인지 배포 검증이 끝난 뒤 나중의 forward-only contract migration으로 보관 또는 삭제해야 합니다.
- 알 수 없거나 비활성 tenant는
/internal/**요청이 컨트롤러 코드에 도달하기 전에 실패하게 만듭니다. - dev 쿠키 동작이 prod와 다른 것은 의도된 차이이며, 표준 dev 설정이 프론트엔드 로컬 + 백엔드 클러스터이기 때문입니다.
- homepage public-read 경로에는 여전히 contract drift가 있습니다.
컨트롤러 comment는 unpublished homepage가
404를 반환해야 한다고 말하지만,HomepageQueryService.Tx.findCurrent()와 관련 테스트는 현재published=false인 저장된 config도 반환합니다.
검증 명령어
cd lumie-backend
./gradlew :modules:auth:test
./gradlew :modules:auth:test --tests '*AuthControllerMeTest'
유용한 테스트:
modules/auth/src/test/java/com/lumie/auth/adapter/in/web/AuthControllerMeTest.javalibs/common/src/test/java/com/lumie/common/tenant/RequestContextFilterTest.java