인증 & 멀티테넌시
JWT 인증 플로우
토큰 추출 순서
JwtAuthenticationFilter는 다음 순서로 JWT를 찾습니다:
Authorization: Bearer <token>헤더lumie_access_token쿠키 (폴백)
JWT Claims 구조
{
"sub": "28",
"tenant_slug": "inst-c704d223",
"tenant_id": 18,
"role": "OWNER",
"type": "access",
"iss": "lumie-auth-svc",
"iat": 1775313347,
"exp": 1775316947,
"jti": "a022ead2-a80e-4091-82ea-1023b9c51560"
}
토큰 검증 단계
- 서명 확인: HMAC-SHA256 (
JWT_SECRET_KEY) - 만료 확인:
expclaim - 블랙리스트 확인: Redis에서 JTI 조회 (로그아웃된 토큰 차단)
- 컨텍스 트 설정:
TenantContextHolder.setTenant+setTenantId,UserContextHolder,SecurityContext
중요: setTenant(slug)만 설정하면 RLS 정책이 행을 숨깁니다. setTenantId도 반드시 호출해야 합니다.
Redis 토큰 저장 구조
refresh_token:{userId}:{jti} → {tokenData} (TTL: 7일)
blacklist:{jti} → "1" (TTL: 액세스 토큰 만료까지)
Spring Security 설정
// app/src/main/java/com/lumie/app/config/SecurityConfig.java
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/register", "/v1/register/owner").permitAll()
.requestMatchers("/v1/login", "/v1/refresh").permitAll()
.requestMatchers("/v1/oauth2/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/internal/**").hasRole("INTERNAL")
.requestMatchers("/v1/payments/paddle/webhook").permitAll()
.requestMatchers("/v1/tenants/public/**").permitAll()
.requestMatchers("/v1/homepage/public/**").permitAll()
.requestMatchers("/v1/files/public/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(internalHmacAuthFilter, JwtAuthenticationFilter.class);
내부 엔드포인트 인증 (HMAC)
/internal/** 경로는 InternalHmacAuthFilter가 먼저 처리합니다. lumie-worker(chatbot-svc 등)가 X-Tenant-Slug, X-Timestamp, X-Signature 헤더로 HMAC-SHA256 서명을 제출해야 합니다. 서명 페이로드: timestamp + "\n" + tenantSlug + "\n" + body. 검증 성공 시 ROLE_INTERNAL 권한이 부여됩니다.
CORS 설정
// CorsConfig.java
config.setAllowedOrigins(List.of(
"https://lumie-edu.com",
"https://dev.lumie-edu.com",
"http://localhost:3000"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
멀티테넌시
RLS 기반 격리
Lumie는 단일 public 스키마를 사용합니다. 테넌트별 스키마를 분리하지 않습니다. 각 테넌트 스코프 테이블에는 tenant_id BIGINT NOT NULL 컬럼과 RLS 정책이 적용됩니다.
| 테이블 유형 | 격리 방식 |
|---|---|
플랫폼 테이블 (tenants, plans, ...) | RLS 없음 |
테넌트 스코프 테이블 (students, exams, ...) | RLS (app.tenant_id GUC 기반) |
컨텍스트 전파
JwtAuthenticationFilter
→ TenantContextHolder.setTenant("inst-c704d223")
→ TenantContextHolder.setTenantId(18)
→ UserContextHolder.setUserId(28)
→ UserContextHolder.setUserRole("OWNER")
↓
@Transactional 진입 → RlsTenantContextAspect
→ SELECT set_config('app.tenant_id', '18', true)
↓
JPA 쿼리 → RLS 자동 필터
TenantContextHolder
// 현재 테넌트 가져오기
String slug = TenantContextHolder.getRequiredTenant(); // "inst-c704d223"
Long tenantId = TenantContextHolder.getRequiredTenantId(); // 18
// 현재 사용자 가져오기
Long userId = UserContextHolder.getRequiredUserId(); // 28
String role = UserContextHolder.getUserRole(); // "OWNER"
OAuth2 지원
auth 모듈은 2개의 OAuth2 프로바이더를 지원합니다:
| 프로바이더 | 용도 |
|---|---|
| 소셜 로그인 | |
| Kakao | 소셜 로그인 |
플로우: 클라이언트 → /v1/oauth2/\{provider\} → 프로바이더 인증 → 콜백 → JWT 발급