[BE-46] 인증 도메인·세션 발급·로그인 API·인증 필터 (FR-70)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 1 인증 방식”, “auth 컨텍스트 인터페이스 시그니처”, “API 계약 1단계 인증”
변경 사항
현재 앱은 Spring Security 의존성 자체가 없어 전 API가 무인증 공개입니다(build.gradle.kts:32-77 security 0건). FR-71이 요구하는 터널 노출을 켜면 그대로 인터넷에 열리므로, 인증이 터널보다 먼저 서야 합니다.
-
자체 세션 토큰 + HttpOnly 쿠키를 채택합니다(Spring Security 전체 스택 미채택 — 사용자 1명·권한 1종이라 가치가 발현되지 않고, 잠금·만료 정책이
SecurityConfig로 흩어져 Rich Domain과 이원화됩니다). -
자격 증명은 DB에 두지 않습니다 — 환경 변수
AUTH_USERNAME·AUTH_PASSWORD_BCRYPT를LoginCredentialGateway(domain interface) 뒤로 숨깁니다. domain/application에@Value·Environment를 직접 주입하지 않습니다. -
토큰 원문은 어디에도 저장하지 않습니다 —
user_login_sessions에는 SHA-256 해시만 둡니다. DB 유출로 세션이 탈취되지 않습니다. -
판정은 전부 Entity에 — 만료(
LoginSession.isExpired()), 연속 실패 5회 잠금·15분 해제(LoginAttemptState.recordFailure()/isLocked())를 엔티티가 스스로 결정합니다. UseCase에if + throw를 두지 않습니다. -
AuthenticationFilter(presentation) —/api/auth/login과/api/webhooks/**를 제외 경로로 미리 등록합니다. BE-47(웹훅)이 이 파일을 다시 건드리지 않게 하는 Single Writer 조치입니다. 필터는auth.required플래그가 OFF면 그대로 통과시킵니다(무중단 전환 창). -
CSRF 토큰은 도입하지 않습니다 — 상태 변경 API가 전부 non-GET이고
SameSite=Lax가 크로스사이트 non-GET에 쿠키를 싣지 않으므로 방어가 성립합니다. 이 근거를 필터 KDoc에 남깁니다. -
GET /api/auth/session이 플래그 상태를 응답합니다 (C-4 — 배포 창의 모순 제거). Release Scenario에서 FE 배포(1-8)가 인증 강제(1-9)보다 먼저라, 그 사이에 “로그인 화면은 뜨는데 API는 열려 있는” 모순이 생깁니다. 응답에authRequired를 실어 FE가 게이트를 우회하게 합니다.auth.required세션 쿠키 응답 OFF 무관 200 { authRequired: false, authenticated: false, username: null, expiresAt: null }ON 없음·만료·무효 401 UNAUTHENTICATED+Set-Cookie: Max-Age=0ON 유효 200 { authRequired: true, authenticated: true, username, expiresAt }/api/auth/session은 인증 제외 경로가 아닙니다 — 플래그 ON + 세션 없음일 때 필터가 401을 내는 것이 곧 계약입니다. 플래그 OFF면 필터가 통과시켜 컨트롤러가authRequired: false를 반환합니다.POST /api/auth/login은 플래그와 무관하게 항상 동작합니다 — 플래그 ON 전에 FE가 로그인 경로를 검증할 수 있어야 합니다.
-
시간 타입은
ZonedDateTime으로 통일하고, 만료 판정은 메서드 내부에서ZonedDateTime.now()로 해결합니다(시간 인자·Clock 주입 금지).
롤백: 피처 플래그 auth.required OFF로 즉시 무인증 복귀. 터널이 아직 꺼져 있는 단계라 안전합니다.
의존
- BE-45 (예외 클래스·에러 코드·설정·의존성)
다이어그램
처리 흐름
sequenceDiagram participant FE as web(SPA) participant F as AuthenticationFilter participant C as AuthApiController participant U as LoginUseCase participant D as AuthDomainService participant R as LoginSessionRepository FE->>C: POST /api/auth/login C->>U: execute(LoginCommand) U->>D: login(username, rawPassword) D->>D: LoginAttemptState.isLocked() 확인 alt 잠금 상태 D-->>U: LoginLockedException else 자격 불일치 D->>D: recordFailure() (5회 도달 시 잠금) D-->>U: InvalidCredentialException else 성공 D->>R: save(LoginSession(tokenHash, expiresAt)) D-->>U: IssuedSession(rawToken, expiresAt) end U-->>C: LoginResult C-->>FE: 200 + Set-Cookie HttpOnly FE->>F: GET /api/** (쿠키 동봉) F->>D: validate(rawToken) F->>F: null이면 401 UNAUTHENTICATED
클래스 의존
flowchart LR subgraph Presentation["presentation/auth"] Filter[AuthenticationFilter] Api[AuthApiController] end subgraph Application["application/auth"] Login[LoginUseCase] Logout[LogoutUseCase] Validate[ValidateSessionUseCase] end subgraph Domain["domain/auth"] DS[AuthDomainService] Session[LoginSession] Attempt[LoginAttemptState] Hasher[PasswordHasher] Gen[SessionTokenGenerator] Cred[LoginCredentialGateway] end Filter --> Validate Api --> Login Api --> Logout Login --> DS Validate --> DS DS --> Session DS --> Attempt DS --> Hasher DS --> Gen DS --> Cred
테스트 케이스
- 올바른 자격 증명으로 로그인하면 200과 HttpOnly·SameSite=Lax 쿠키가 내려간다
- 발급된 쿠키로 보호 경로를 호출하면 통과한다
- 쿠키 없이 보호 경로를 호출하면 401
UNAUTHENTICATED다 - 만료된 세션 쿠키로 호출하면 401과 함께
Max-Age=0쿠키 삭제 지시가 내려간다 - 비밀번호가 틀리면 401
INVALID_CREDENTIAL이고 실패 카운트가 1 증가한다 - 연속 5회 실패 후 6번째 시도는 429
LOGIN_LOCKED다 (경계값) - 잠금 후 15분이 지나면 다시 로그인할 수 있다
- 로그인 성공 시 실패 카운트가 0으로 초기화된다
- 로그아웃 후 같은 쿠키로 호출하면 401이다
- 세션이 없는 상태에서 로그아웃해도 204다 (멱등)
/api/auth/login은 인증 없이 통과한다 (제외 경로)/api/webhooks/**는 인증 없이 통과한다 (제외 경로 — BE-47 선행 등록)auth.required플래그가 OFF면 쿠키 없이도 보호 경로가 통과한다 (무중단 전환)- 플래그 OFF + 쿠키 없음이면
GET /api/auth/session이 200{ authRequired: false, authenticated: false }다 - 플래그 ON + 쿠키 없음이면
GET /api/auth/session이 401UNAUTHENTICATED다 - 플래그 ON + 유효 쿠키면
GET /api/auth/session이 200{ authRequired: true, authenticated: true }다 - 플래그 OFF 상태에서도
POST /api/auth/login이 정상 동작하고 쿠키를 발급한다 - DB에 저장된 세션 값이 토큰 원문과 다르다 (해시 저장 검증)
- 만료 시각은 발급 시각 + 24시간이다