[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이 요구하는 터널 노출을 켜면 그대로 인터넷에 열리므로, 인증이 터널보다 먼저 서야 합니다.

  1. 자체 세션 토큰 + HttpOnly 쿠키를 채택합니다(Spring Security 전체 스택 미채택 — 사용자 1명·권한 1종이라 가치가 발현되지 않고, 잠금·만료 정책이 SecurityConfig로 흩어져 Rich Domain과 이원화됩니다).

  2. 자격 증명은 DB에 두지 않습니다 — 환경 변수 AUTH_USERNAME·AUTH_PASSWORD_BCRYPTLoginCredentialGateway(domain interface) 뒤로 숨깁니다. domain/application에 @Value·Environment를 직접 주입하지 않습니다.

  3. 토큰 원문은 어디에도 저장하지 않습니다user_login_sessions에는 SHA-256 해시만 둡니다. DB 유출로 세션이 탈취되지 않습니다.

  4. 판정은 전부 Entity에 — 만료(LoginSession.isExpired()), 연속 실패 5회 잠금·15분 해제(LoginAttemptState.recordFailure()/isLocked())를 엔티티가 스스로 결정합니다. UseCase에 if + throw를 두지 않습니다.

  5. AuthenticationFilter(presentation)/api/auth/login/api/webhooks/**를 제외 경로로 미리 등록합니다. BE-47(웹훅)이 이 파일을 다시 건드리지 않게 하는 Single Writer 조치입니다. 필터는 auth.required 플래그가 OFF면 그대로 통과시킵니다(무중단 전환 창).

  6. CSRF 토큰은 도입하지 않습니다 — 상태 변경 API가 전부 non-GET이고 SameSite=Lax가 크로스사이트 non-GET에 쿠키를 싣지 않으므로 방어가 성립합니다. 이 근거를 필터 KDoc에 남깁니다.

  7. 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=0
    ON유효200 { authRequired: true, authenticated: true, username, expiresAt }
    • /api/auth/session은 인증 제외 경로가 아닙니다 — 플래그 ON + 세션 없음일 때 필터가 401을 내는 것이 곧 계약입니다. 플래그 OFF면 필터가 통과시켜 컨트롤러가 authRequired: false를 반환합니다.
    • POST /api/auth/login은 플래그와 무관하게 항상 동작합니다 — 플래그 ON 전에 FE가 로그인 경로를 검증할 수 있어야 합니다.
  8. 시간 타입은 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이 401 UNAUTHENTICATED
  • 플래그 ON + 유효 쿠키면 GET /api/auth/session이 200 { authRequired: true, authenticated: true }
  • 플래그 OFF 상태에서도 POST /api/auth/login이 정상 동작하고 쿠키를 발급한다
  • DB에 저장된 세션 값이 토큰 원문과 다르다 (해시 저장 검증)
  • 만료 시각은 발급 시각 + 24시간이다