Skip to main content

Auth 모듈

Auth 모듈은 Lumie 플랫폼의 인증/인가를 담당합니다. JWT 기반 인증, OAuth2 소셜 로그인, 사용자 관리, 토큰 관리를 제공합니다.

모듈 개요

  • 배포: lumie-backend 모놀리스의 modules/auth
  • 데이터베이스: PostgreSQL RLS 테넌트 스코프 users, public.owner_directory (pre-auth OWNER 라우팅), Redis (토큰 저장)
  • 주요 의존성: TenantService(internal-api)

주요 기능

1. 인증 (Authentication)

  • JWT 기반 액세스/리프레시 토큰
  • 사용자 등록 및 로그인
  • OAuth2 소셜 로그인 (Google, Kakao, Keycloak)
  • 원장 등록 (테넌트 생성 포함)

2. 인가 (Authorization)

  • Spring Security 필터 체인 기반 Role 접근 제어
  • 테넌트별 사용자 격리
  • JwtAuthenticationFilter — 모든 요청에서 JWT를 추출하고 SecurityContext 설정

3. 토큰 관리

  • Redis 기반 리프레시 토큰 저장
  • 토큰 블랙리스트 관리 (JTI 기반)
  • 쿠키 폴백 (lumie_access_token)

4. 사용자 관리

  • RLS 테넌트 스코프 users 테이블 관리
  • public.owner_directory를 통한 로그인 전 OWNER → 테넌트 라우팅
  • 사용자 생성/삭제 (AuthService — 다른 모듈이 호출)

헥사고날 아키텍처

modules/auth/src/main/java/com/lumie/auth/
├── adapter/
│ ├── in/web/ # REST 컨트롤러 (AuthController, OAuth2Controller)
│ ├── in/security/ # JwtAuthenticationFilter, JwtTokenProvider
│ ├── in/internal/ # AuthService 구현체 (StaffServiceAdapter 등 타 모듈 호출용)
│ └── out/{persistence,external,config}/
├── application/
│ ├── service/ # AuthCommandService, TokenService, OAuth2Service
│ └── port/out/ # LoadUserPort, SaveUserPort, TokenStorePort
└── domain/
├── entity/User.java
└── vo/TokenClaims.java

JwtAuthenticationFilter

모든 보호된 요청에 실행되는 필터입니다. app 모듈의 SecurityConfig에 등록됩니다.

// modules/auth/.../adapter/in/security/JwtAuthenticationFilter.java
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final ValidateTokenUseCase validateTokenUseCase;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);

if (token != null) {
Optional<TokenClaims> claimsOpt = validateTokenUseCase.validate(token);

if (claimsOpt.isPresent()) {
TokenClaims claims = claimsOpt.get();

AuthenticatedUser principal = new AuthenticatedUser(
claims.getUserId(), claims.tenantSlug(),
claims.tenantId(), claims.role());

SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
principal, null,
List.of(new SimpleGrantedAuthority("ROLE_" + claims.role().name()))));

// TenantContextHolder / UserContextHolder 설정
TenantContextHolder.setTenant(claims.tenantSlug());
TenantContextHolder.setTenantId(claims.tenantId());
UserContextHolder.setUserId(claims.getUserId());
UserContextHolder.setUserRole(claims.role().name());
}
}

filterChain.doFilter(request, response);
}

private String extractToken(HttpServletRequest request) {
// 1. Authorization: Bearer <token> 헤더
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
// 2. lumie_access_token 쿠키 폴백
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("lumie_access_token".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/v1/login") ||
path.startsWith("/v1/register") ||
path.startsWith("/v1/refresh") ||
path.startsWith("/v1/oauth2") ||
path.startsWith("/actuator");
}
}

인증 플로우

일반 로그인

OAuth2 소셜 로그인

원장 등록 플로우

API 명세

사용자 등록

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

{
"tenantSlug": "awesome-academy",
"userLoginId": "student001",
"password": "password123",
"name": "김학생",
"phone": "010-1234-5678"
}

로그인

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

{
"userLoginId": "student001",
"password": "password123"
}

응답:

{
"accessExpiresIn": 3600,
"refreshExpiresIn": 604800,
"user": {
"id": 1,
"userLoginId": "student001",
"name": "김학생",
"role": "STUDENT",
"tenantSlug": "awesome-academy"
}
}

원장 등록

POST /v1/register/owner
Content-Type: application/json

{
"instituteName": "어썸 학원",
"businessRegistrationNumber": "123-45-67890",
"userLoginId": "owner@awesome.com",
"password": "password123",
"name": "김원장"
}

OAuth2 인증

# 인증 URL 생성
GET /v1/oauth2/google/authorize?tenant_slug=awesome-academy

# 콜백 처리
GET /v1/oauth2/google/callback?code=xxx&state=yyy

토큰 갱신

POST /v1/refresh
Cookie: lumie_refresh_token=eyJ...

로그아웃

POST /v1/logout
Authorization: Bearer eyJ...

internal-api

AuthService 인터페이스를 구현하여 다른 모듈이 사용자 정보를 조회하거나 계정을 생성/삭제할 수 있도록 합니다.

// libs/internal-api/src/main/java/com/lumie/auth/api/AuthService.java
public interface AuthService {
TokenValidationResult validateToken(String token);
Optional<UserData> getUserInfo(String token);
CreateUserResult createUser(String name, String phone, String role, Long tenantId);
DeleteUserResult deleteUser(Long userId);
ResetPasswordResult resetPassword(Long userId);
ChangeLoginIdResult changeLoginId(Long userId, String newLoginId);
Map<Long, String> getAvatarSeeds(Collection<Long> userIds);
}

JWT 토큰 구조

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

토큰 관리 전략

  • 액세스 토큰: 1시간 만료, Stateless
  • 리프레시 토큰: 7일 만료, Redis 저장
  • 토큰 블랙리스트: 로그아웃 시 JTI를 Redis에 저장

Redis 토큰 저장 구조

refresh_token:{userId}:{jti} → {tokenData} (TTL: 7일)
blacklist:{jti} → "1" (TTL: 액세스 토큰 만료까지)

보안 설계

비밀번호 보안

  • BCrypt 해싱 (BCryptPasswordEncoder)
  • 최소 8자, 복잡성 정책 적용

OAuth2 보안

  • State 파라미터로 CSRF 방지
  • 필요한 scope만 요청 (최소 권한 원칙)

데이터베이스 설계

사용자 테이블 (users, RLS 적용)

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
user_login_id VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
role VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
oauth_provider VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_login_id ON users(user_login_id);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);

로그인 전에는 테넌트 컨텍스트가 없으므로 users를 직접 조회하지 않습니다. OWNER 로그인 라우팅은 RLS가 없는 public.owner_directory에서 user_login_idtenant_id를 먼저 찾은 뒤, 해당 테넌트 컨텍스트로 사용자 인증을 진행합니다.

설정

관련 설정은 모두 app/src/main/resources/application.yaml에서 관리됩니다.

jwt:
secret-key: ${JWT_SECRET_KEY}
access-expiration: ${JWT_ACCESS_EXPIRATION:3600}
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800}
issuer: lumie-auth-svc

cookie:
domain: ${COOKIE_DOMAIN:}
secure: ${COOKIE_SECURE:true}
http-only: true
same-site: Lax
access-token-name: lumie_access_token
refresh-token-name: lumie_refresh_token

oauth2:
google:
client-id: ${GOOGLE_CLIENT_ID:}
client-secret: ${GOOGLE_CLIENT_SECRET:}
redirect-uri: ${GOOGLE_REDIRECT_URI}
kakao:
client-id: ${KAKAO_CLIENT_ID:}
client-secret: ${KAKAO_CLIENT_SECRET:}
redirect-uri: ${KAKAO_REDIRECT_URI}
keycloak:
client-id: ${KEYCLOAK_CLIENT_ID:lumie-app}
client-secret: ${KEYCLOAK_CLIENT_SECRET:}
redirect-uri: ${KEYCLOAK_REDIRECT_URI}

트러블슈팅

토큰 검증 실패

# Redis 연결 확인
redis-cli -h <host> ping

# 토큰 블랙리스트 확인
redis-cli get blacklist:{jti}

# 리프레시 토큰 확인
redis-cli get refresh_token:{userId}:{jti}

OAuth2 인증 실패

# 환경 변수 확인
echo $GOOGLE_CLIENT_ID
echo $GOOGLE_CLIENT_SECRET

관련 문서