Skip to content

feat(auth): 토큰 폐기 즉시 반영 · RT 재사용 탐지 · 설정 기본값 정리 - #74

Merged
imeasy99 merged 12 commits into
devfrom
feat/passport-trust-boundary
Aug 30, 2026
Merged

feat(auth): 토큰 폐기 즉시 반영 · RT 재사용 탐지 · 설정 기본값 정리#74
imeasy99 merged 12 commits into
devfrom
feat/passport-trust-boundary

Conversation

@imeasy99

Copy link
Copy Markdown
Collaborator

#️⃣ 연관된 이슈

없음 (보안 개선)

📝 작업 내용

인증 판정에서 발견한 문제들을 고칩니다.

1. 임의 사용자 AT를 발급하던 테스트 엔드포인트 제거
/api/public/token이 인증 없이 opaqueId만 넘기면 유효 서명 AT를 발급했습니다(코드에 "나중에 제거 예정" 주석). 게이트웨이가 이 경로를 라우팅하지 않아 인터넷에서 직접 닿진 않으나, 내부망에 닿기만 하면 전 계정 탈취입니다.

2. 로그아웃·탈퇴 즉시 반영
기존에는 RT만 폐기되고 AT는 만료(30분)까지 유효했습니다. tokens_invalid_before 타임스탬프 하나를 두고 introspect에서 판정합니다. 세 가지를 거릅니다.

  • 회원 행 부재 = 탈퇴(hard delete). 행이 없으면 tokens_invalid_before를 읽을 수 없으므로 부재 자체를 폐기 신호로 씁니다
  • soft delete 된 계정 = 계정 병합으로 흡수된 회원
  • iat <= tokens_invalid_before

3. RT 재사용 탐지
회전은 하고 있었으나 탐지가 없어, 도난된 RT를 공격자가 먼저 쓰면 정상 사용자만 로그아웃되고 아무도 몰랐습니다. 이미 회전된 jti가 다시 오면 토큰 패밀리 전체를 폐기하고 Google Chat으로 알립니다.

유예 창(기본 30초)을 둔 이유 — AT 수명이 30분이고 잔여 5분 미만이면 모든 introspect가 갱신을 탑니다. 그 구간에 API를 동시 호출하면 같은 RT로 갱신이 겹쳐 정상 사용자가 도난으로 오인됩니다. silentRefresh는 실패 시 쿠키를 지우므로 그대로 두면 정상 사용자가 로그아웃됩니다. 유예 창 안에서는 정상 발급으로 이어가 사용자에게 보이지 않게 했습니다.

4. 인증 전 요청의 레이트리밋 버킷 분리
SecurityContext가 비어 있는 요청이 전부 anonymous 버킷(60회/분)을 공유했습니다. AT 만료 직후 트래픽이 몰리면 무관한 사용자가 429를 맞습니다. 클라이언트 IP로 나눕니다(게이트웨이 뒤라 X-Forwarded-For 우선).

5. /internal-api/** 보호를 필터로 승격
X-Internal-Secret 확인이 컨트롤러 메서드 안에 있어, 새 내부 컨트롤러에서 빠뜨리면 그대로 노출되는 구조였습니다. 경로 단위로 강제하고 비교는 상수시간으로 합니다.

6. 하드코딩 기본값 제거
JWT_SECRET의 기본값이 jwt-secret이었습니다 — env 주입이 빠지면 저장소를 본 사람이면 누구나 아는 키로 토큰을 서명·검증합니다. DB_PASSWORD·INTERNAL_LOGGING_SECRET도 함께 제거했습니다. 설정 누락은 조용히 잘못 도는 것보다 기동 실패로 드러나는 편이 낫습니다.

배포 전 필요한 환경변수

AUTH_BE_ENV_FILE(production/alpha)에 아래가 있어야 기동합니다.

  • INTERNAL_LOGGING_SECRET — 게이트웨이와 동일한 값
  • SECURITY_ALERT_CHAT_WEBHOOK_URL — 보안 전용 채널

📷 스크린샷

해당 없음 (서버 로직)

💬 리뷰 요구사항

1. 폐기 판정의 반환형MemberRevocationState?로 둔 이유입니다. 스칼라 값 하나만 뽑으면 "회원 없음"과 "폐기 이력 없음"이 둘 다 null로 뭉개져 구분할 수 없고, 그러면 탈퇴 직후 옛 AT가 통과합니다.

2. RT 재사용 유예 창 길이 — 기본 30초입니다(AUTH_REFRESH_REUSE_GRACE_SECONDS). 도난 탐지가 그만큼 늦어지는 대가로 정상 사용자 오탐을 막습니다. 운영 로그를 보고 조정할 수 있게 설정값으로 뺐습니다.

3. introspect 핫패스 성능 — AT 유효 경로에 DB 읽기가 하나 늘었습니다. opaque_id unique 인덱스 단일 조회이고 컬럼 두 개만 읽으며, 게이트웨이 introspect 캐시(TTL 45초)가 앞단에서 대부분을 막습니다. 배포 후 응답시간 관찰이 필요합니다.

4. 알림이 본 요청을 방해하지 않는지@Async + 예외 삼킴 + 3초 타임아웃. 동기로 부르면 채널이 느릴 때 @Transactional 안에서 DB 트랜잭션이 네트워크 왕복만큼 열립니다.

✅ 검증

  • ./gradlew clean build 통과 · 테스트 115건
  • 로컬 .env를 치운 상태에서도 통과 — 테스트가 gitignore된 파일에 의존하지 않도록 필수 설정을 명시했습니다

인증 없이 opaqueId만 넘기면 유효 서명 AT를 발급하는 /api/public/token이
코드에 남아 있었다(주석에 '나중에 제거 예정'). 게이트웨이가 /api/public/**을
라우팅하지 않아 인터넷에서 직접 닿지는 않으나, 내부망에 닿기만 하면
전 계정 탈취가 된다. SSRF 하나, 컨테이너 침해 하나, 라우트 한 줄이면 실현된다.

컨트롤러 파일을 삭제하고 SecurityConfig의 permitAll 목록에서도
/api/public/**을 제거한다.

이 엔드포인트에 의존하던 통합 테스트 2건을 옮겼다.
- 인증 필요 경로: 토큰을 발급하지 않는 테스트 전용 프로브 컨트롤러
  (src/test 에만 존재하므로 배포 아티팩트에 포함되지 않는다)
- permitAll 경로: /v3/api-docs

작업 중 확인한 것 — permitAll 경로로 /actuator/health를 먼저 시도했으나
테스트 환경에서 health 인디케이터가 DOWN이라 503이 났다. 보안 통과 여부가
아니라 인프라 상태를 재는 테스트가 되어 버려 외부 의존이 없는
/v3/api-docs로 바꿨다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
로그아웃 시 RT만 폐기되고 AT는 만료(30분)까지 그대로 유효했다.
세션 전체를 stateful 하게 만들지 않기 위해 사용자당 타임스탬프 하나만 두고
로그아웃·탈퇴 시각을 기록한다.

이 커밋은 값을 쌓기만 한다 — 읽는 쪽이 아직 없어 동작 변화가 없다.
introspect의 폐기 판정은 다음 커밋에서 붙인다.

logout·withdraw 모두 @transactional이라 dirty checking으로 반영된다(save 불필요).

탈퇴는 회원 행 자체를 삭제하므로 이 값이 남지 않는다. 삭제 트랜잭션 커밋
전후의 짧은 창과 게이트웨이 introspect 캐시를 위해 기록하되, 삭제 이후의
판정은 다음 커밋의 '회원 부재 = 폐기' 규칙이 담당한다.

ddl-auto: update(local·alpha·prod 전부)라 마이그레이션 파일은 없다.
기존 행은 NULL(폐기 이력 없음)이 된다. 롤백해도 컬럼만 남고 무해하다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
로그아웃·탈퇴 후에도 옛 AT가 만료(30분)까지 통과하던 문제를 닫는다.

두 가지를 모두 거른다.
- 회원 행 부재(=탈퇴): 탈퇴는 hard delete라 tokens_invalid_before를 읽을 수
  없다. 행 부재 자체를 폐기 신호로 쓴다.
- iat <= tokens_invalid_before: 로그아웃·탈퇴 이전 발급분.
  iat는 초 단위라 같은 초에 발급된 토큰도 거부하도록 isAfter의 부정으로 비교한다.

설계 문서와 달라진 점 — 설계 초안의 판정은
findTokensInvalidBeforeByOpaqueId(...) ?: return 형태였는데, 탈퇴가 회원 행을
삭제하므로 조회 결과가 null이 되어 그대로 통과했다. 즉 탈퇴 계정의 옛 AT가
살아남는다(설계서 S8 시나리오가 성립하지 않음). 이를 막기 위해 리포지토리
반환형을 Instant?가 아니라 List<Instant?>로 두었다 — 스칼라 프로젝션에서
Instant?를 쓰면 '회원 없음'과 '폐기 이력 없음'이 둘 다 null로 뭉개져
구분할 수 없기 때문이다.

판정은 컨트롤러가 아니라 AuthService에 두었다. AuthController는 리포지토리를
주입받지 않으며, 기존 계층(controller → service → repository)을 지킨다.

silent refresh 경로에는 넣지 않았다. 로그아웃 시 RT가 softDelete되어
findActiveByOpaqueIdAndJti가 먼저 실패하므로 두 방어가 서로 보완한다.

성능: AT 유효 경로에 DB 읽기가 하나 늘어난다(opaque_id unique 인덱스 단일 조회,
idx_member_opaque_id 존재 확인함). 게이트웨이 introspect 캐시(TTL 45초)가
앞단에서 막아 캐시 미스에서만 발생한다. 배포 후 응답시간을 관찰할 것.

테스트 8건 추가(getIssuedAt 3건, 폐기 판정 5건).

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
회전(rotation)은 하고 있었으나 탐지가 없었다. 도난된 RT를 공격자가 먼저 쓰면
① 공격자는 새 AT/RT 쌍을 얻고 ② 정상 사용자의 다음 갱신만 실패해
정상 사용자만 로그아웃되며 ③ 도난 사실은 아무도 모른다.

active가 없을 때 '존재한 적 없는 jti'와 '이미 회전되어 폐기된 jti'를 구분한다.
후자면 도난 정황으로 보고 해당 사용자의 RT 전량과 AT(tokens_invalid_before)를
폐기한다. 정상 사용자도 재로그인해야 하지만 계정이 탈취된 상황에서는 그게 옳다.

구분을 위해 리포지토리 메서드 2개를 추가했다 — 기존에는 삭제분 포함 조회와
패밀리 전체 폐기가 없었다.
- findByOpaqueIdAndJtiIncludingDeleted
- softDeleteAllByOpaqueId

보안 알림 채널 통지는 TODO로 남겼다 — 전송 대상 미정(운영 협의 필요).
단순 오류·만료 토큰까지 패밀리를 폐기하지 않도록 테스트로 고정했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
AT가 만료되어 silent refresh를 타는 introspect 요청은 SecurityContext가
비어 있어 버킷 키가 전부 'anonymous'였다. 60회/분 버킷을 전 사용자가
나눠 쓰는 셈이라, AT 만료 직후 트래픽이 몰리면 무관한 사용자가 429를 맞는다.

인증 전에는 클라이언트 IP를 키로 쓴다. 이 서비스는 게이트웨이 뒤에 있어
remoteAddr이 게이트웨이 IP로 고정되므로 X-Forwarded-For 첫 값을 먼저 본다.
그러지 않으면 다시 버킷 하나를 공유하게 된다.

AT 수명 단축을 검토하게 되면 이 변경이 선행 조건이다 — AT를 짧게 하면
refresh 트래픽이 몇 배로 늘고 그 경로가 바로 이 버킷을 탄다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
X-Internal-Secret 확인이 InternalMemberController 메서드 안에 있어,
새 내부 컨트롤러를 추가하면서 빠뜨리면 그대로 노출되는 구조였다.
SecurityConfig에서 해당 경로가 permitAll이기 때문이다.

경로 단위로 강제되도록 OncePerRequestFilter로 올린다.
비교는 상수시간(MessageDigest.isEqual)으로 한다 — 일반 문자열 비교는
앞에서부터 다른 문자를 만나면 즉시 반환하므로 응답 시간 차이로
시크릿을 한 글자씩 추측당할 수 있다.

컨트롤러 내부의 기존 검사는 이중 방어로 남긴다.

작업 중 걸린 것 — KDoc 안에 내부 API 경로를 와일드카드까지 적었더니
'Unclosed comment' 컴파일 에러가 났다. Kotlin 블록 주석은 Java와 달리
중첩되므로 주석 안의 슬래시+별표가 nested comment opener로 읽힌다.
주석에서는 와일드카드를 빼고 표기했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
MemberEntity.softDelete()가 실제로 호출되는 곳이 있다 — MemberConnector가
계정 병합 시 흡수된 회원을 soft delete 한다. 행은 남고 isDeleted=true만 된다.

폐기 판정이 isDeleted를 보지 않아, 흡수된 opaqueId의 AT가 만료(30분)까지
계속 통과했다. 탈퇴 hard delete 건과 같은 유형의 구멍이다. 남의 계정 사칭은
아니고 '없어졌어야 할 자기 옛 신원이 더 살아 있는' 상황이지만, 방치하면
다음에 soft delete를 쓰는 코드에서 또 뚫린다.

두 겹으로 막는다.
1. 사건이 일어난 곳에서 기록 — MemberConnector가 softDelete() 직전에
   revokeTokens()를 호출한다.
2. 판정에서도 확인 — assertTokenNotRevoked가 isDeleted를 거른다. 앞으로 1을
   빠뜨리는 코드가 생겨도 이 방어선이 막는다(internal-api 시크릿 검사를
   컨트롤러에서 필터로 올린 것과 같은 논리).

판정에 isDeleted까지 필요해져 조회 반환형을 List<Instant?>에서
MemberRevocationState?(data class)로 바꿨다. null = 회원 없음이 타입으로
드러나고, 판정 로직은 Kotlin에 남아 단위 테스트로 분기를 고정할 수 있다.
조건을 JPQL에 넣는 방식은 로직이 mock 뒤로 숨어 단위 테스트가 무의미해지므로
택하지 않았다.

작업 중 걸린 것 — 처음에 인터페이스 프로젝션으로 만들었더니 isDeleted가 항상
null로 들어와 Kotlin non-null 타입에서 NPE가 났다. Kotlin의 val isDeleted는
게터가 isDeleted()로 컴파일되고 Spring Data는 프로퍼티명을 'deleted'로
해석하는데, 쿼리 별칭은 isDeleted였다. 매칭 실패가 예외가 아니라 조용한 null로
나타난다. 이름 매칭에 의존하지 않는 생성자 표현식으로 바꿨다.

이 실패는 리포지토리를 mock하는 단위 테스트로는 잡히지 않는다. 실제 DB(H2)를
타는 통합 테스트 4건을 함께 추가했고, 그 테스트가 문제를 잡아냈다.
단위 테스트도 병합 케이스 2건을 포함해 7건으로 늘렸다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
직전 커밋의 재사용 탐지가 정상 사용자를 로그아웃시킬 수 있었다.

경위 — AT 수명은 30분이고 잔여 5분 미만이면 모든 introspect 가 silent refresh 를
탄다. 즉 활성 사용 중 25~30분마다 한 번씩 갱신 구간에 들어간다. 그 구간에
페이지가 API 를 여러 개 동시 호출하면 같은 RT 로 갱신이 겹치고, 하나만 성공한 뒤
나머지는 '이미 회전된 jti'로 보인다. 도난으로 판정하면 토큰 패밀리가 폐기되어
사용자가 재로그인해야 한다.

더 중요한 것은 실패가 조용하지 않다는 점이다. silentRefresh 는 실패 시
clearAuthCookies() 로 쿠키를 지운다. 그래서 '패밀리는 두고 401만' 으로는
로그아웃을 막지 못한다 — 유예 창 안에서는 정상 발급까지 이어가야 한다.

active 가 없을 때를 세 갈래로 나눈다.
1. 존재한 적 없는 jti        → 401, 패밀리 유지
2. 유예 창 내에 회전된 jti   → 정상 발급 (사용자에게 보이지 않음)
3. 그보다 오래된 jti         → 도난 정황: 패밀리 폐기 + 401

유예는 app.auth.refresh-reuse-grace-seconds (기본 30초)로 뺐다. 운영 로그를 보고
env 만 고쳐 조정할 수 있다. 0 으로 두면 유예 없이 동작한다.

대가 — 유예 창 안에서는 도난 토큰도 통과한다. 다만 공격자가 정상 회전 직후
몇 초 안에 써야 하므로 창이 매우 좁고, 실제 도난은 대개 한참 뒤에 나타난다.
정상 사용자가 주기적으로 튕기는 쪽이 더 큰 피해라고 판단했다.

테스트 3건으로 세 갈래를 고정했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
탐지는 되는데 log.warn 에만 남아 아무도 모르는 상태였다. 계정 탈취 정황이므로
운영 채널로 알린다.

알림은 절대 본 요청을 방해하지 않도록 만들었다.
- @async — 인증 요청 스레드를 잡지 않는다. 동기로 부르면 채널이 느릴 때
  로그인·갱신이 함께 느려지고, 호출부가 @transactional 이라 DB 트랜잭션이
  네트워크 왕복만큼 열린 채 유지된다.
- 어떤 예외도 밖으로 내보내지 않는다. 알림 실패가 인증 실패가 되면 안 된다.
- 타임아웃은 기존 RestClient 빈이 갖고 있다(연결 3초 / 응답 3초).

auth-api 는 이미 네이버·구글·카카오 OAuth 로 외부 호출을 하고 있어 egress 가
새로 열리는 것은 아니다.

메시지에 자격증명을 담지 않는다. opaqueId 와 jti(토큰 값이 아님), 시각만 싣는다.
웹훅 URL 자체가 시크릿이라 로그에도 남기지 않는다.
설정이 없으면 전송을 건너뛰고 경고만 남긴다 — env 없이도 기동한다.

알림은 도난 정황일 때만 보낸다. 유예 창 안의 동시 요청까지 울리면 신호가
오염되어 진짜 도난을 놓친다. 테스트로 고정했다.

전송 검증은 목킹이 아니라 로컬 스텁 HTTP 서버로 했다. 실제로 어떤 본문이 어떤
헤더로 나가는지 확인해야 Google Chat 의 {"text": ...} 계약을 고정할 수 있다.
5xx 응답·연결 불가·URL 미설정 세 경우 모두 예외가 새지 않는 것도 확인했다.

설정 2개 추가:
- app.auth.refresh-reuse-grace-seconds (AUTH_REFRESH_REUSE_GRACE_SECONDS, 기본 30)
- app.alert.google-chat-webhook-url (GOOGLE_CHAT_WEBHOOK_URL, 기본 비어 있음)

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
보안 전용 채널을 따로 쓰기로 하면서 GOOGLE_CHAT_WEBHOOK_URL 이라는 이름이
문제가 됐다. wedding 저장소가 같은 이름을 일반 운영 알림(Sentry 변환·캐시 워밍)에
쓰고 있어서, 이름이 같으면 저장소 간에 값을 옮기다가 보안 경보가 일반 채널로
새거나 그 반대가 되기 쉽다. 같은 이름이 저장소마다 다른 채널을 가리키는 상태는
설정 실수를 부른다.

app.alert.security-chat-webhook-url / SECURITY_ALERT_CHAT_WEBHOOK_URL 로 바꿨다.
이름만으로 어느 채널인지 드러난다.

미설정 시 경고 로그에도 정확한 변수명을 넣어, 알림이 안 올 때 무엇을 설정해야
하는지 로그만 보고 알 수 있게 했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
alpha 와 prod 가 같은 웹훅 채널을 공유하기로 해서, 어느 환경 사고인지
한눈에 구분돼야 한다. 기존에는 환경이 본문 둘째 줄에 있어 목록에서
훑을 때 보이지 않았다.

제목 맨 앞으로 옮기고 대문자로 표시한다.
  🚨 *[PROD] RT 재사용 감지 — 계정 탈취 정황*
  🚨 *[ALPHA] RT 재사용 감지 — 계정 탈취 정황*

환경값은 spring.profiles.active 를 그대로 쓴다. 배포 워크플로가 main → prod,
dev → alpha 로 넣어주므로 별도 설정이 필요 없다.

테스트에 환경 표시 검증을 추가했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
기본값이 있으면 설정 주입이 빠져도 애플리케이션이 잘못된 값으로 조용히 뜬다.
없으면 기동 자체가 실패해 배포 시점에 바로 드러난다. 후자가 원인 추적이 쉽다.

제거한 것 (전부 공개돼선 안 되거나 환경마다 달라야 하는 값)
- JWT_SECRET: 기본값이 'jwt-secret' 이었다. env 주입이 빠지면 코드에 적힌 알려진
  키로 토큰을 서명·검증하게 된다 — 저장소를 본 사람이면 누구나 토큰을 위조할 수 있다.
  이 파일의 access-exp·refresh-exp 는 이미 기본값이 없어 일관성도 맞지 않았다.
- DB_PASSWORD: 기본값 'postgres'. 자격증명은 코드에 두지 않는다.
- INTERNAL_LOGGING_SECRET: 기본값 'default-secret'.
- SECURITY_ALERT_CHAT_WEBHOOK_URL: 웹훅 URL 자체가 시크릿이다.

유지한 것 — 공개돼도 문제없고 누락 시 조용히 잘못 동작하지 않는 값.
포트·프로파일·로그레벨·타임존·스웨거 경로·DB 호스트/포트/이름/사용자,
그리고 순수 튜닝 상수(캐시 TTL, RT 재사용 유예 30초).

테스트를 자립적으로 만들었다. 이전에는 gitignore 된 로컬 .env 에 값이 있어야
통과했다 — 새로 클론한 개발자나 CI 에서는 재현되지 않는 실패가 난다.
SpringBootTest 4곳에 필수 설정을 명시했고, .env 를 치운 상태에서 115건 통과를
확인했다. 이제 그 목록이 '기동에 필요한 설정'의 문서 역할을 한다.

배포 전 필요 — 두 GitHub Environment(production/alpha)의 AUTH_BE_ENV_FILE 에
INTERNAL_LOGGING_SECRET 과 SECURITY_ALERT_CHAT_WEBHOOK_URL 이 있어야 한다.
없으면 기동에 실패한다(의도된 동작).

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
@imeasy99 imeasy99 self-assigned this Aug 30, 2026
@imeasy99 imeasy99 added the ✨ feature 기능 추가 label Aug 30, 2026
@imeasy99
imeasy99 merged commit e99895d into dev Aug 30, 2026
imeasy99 added a commit that referenced this pull request Aug 30, 2026
* Update deploy.yml

* feat: 환경 및 로깅 설정 개선

- `application-prod.yml`, `application-alpha.yml`에 `project.logging.env` 추가
- Logback 설정 간소화 및 기본 설정 파일(`base-logback.xml`) 포함
- `GlobalExceptionHandler`에서 예외 처리 시 로그 수준 분리 (4xx는 info, 기타는 error)

* feat: API 명세 문서 추가

- API 명세 문서 작성 및 저장소에 추가(`docs/api-명세서.md`)
- GitHub Pages 경로(`/api-명세서/`) 설정 및 문서화
- 저장소 디렉토리 구조 및 실행 가이드 갱신

* feat: 소셜 로그인 V2 API 추가 및 기존 로직 개선

- `/api/v2/auth/*/login` 경로 및 컨트롤러(SocialLoginV2Controller) 추가
  - X-Client-Id 헤더 기반 응답 분기를 통해 웹/앱 클라이언트 지원
- OAuth 제공자별 Google, Kakao, Naver 소셜 로그인 V2 API 구현
- SecurityConfig에서 V2 로그인 경로 추가
- SocialLoginResponseDto 클래스 추가를 통한 앱 응답 전용 DTO 제공
- AuthController에 AT/RT 처리 방식 개선 (웹/앱 구분 로직 추가)
- deploy.yml 경로 설정에 문서 변경 무시 규칙 추가

* feat: AT/RT 처리 방식 개선 및 사일런트 리프레시 로직 리팩토링

- AT/RT 처리 로직에서 사일런트 리프레시 분리 및 재사용성 강화
- 토큰 유효 여부, 만료 처리 방식 변경 및 관련 로직 최적화
- 웹/앱 클라이언트 구분에 따른 쿠키/헤더 처리 방식 개선
- 서비스 응답 헤더 설정 로직 리팩토링

* feat: Google 앱 로그인 서비스 추가

- Google Credential Manager에서 발급받은 ID Token 검증 로직 구현
- 자체 JWT 발급 및 Google ID Token 검증 처리 로직 추가
- `GoogleAppLoginService` 클래스 및 관련 엔티티, 예외 처리 로직 구성
- 로그 추가를 통해 인증 및 검증 흐름 추적 가능

* feat: 안드로이드 앱 전용 Google ID Token 로그인 API 추가

* feat: Google 앱 로그인 API에서 X-Client-Id 헤더 제거

- Google ID Token 검증 과정에서 불필요한 X-Client-Id 헤더 제거
- 관련 API 명세 문서에서 X-Client-Id 헤더 설명 삭제
- 컨트롤러 메서드 파라미터 수정 및 필요 없는 코드 정리

* feat: 안드로이드 앱 전용 Google ID Token 로그인 경로 추가

- `/api/v1/auth/google/login/app` 경로 SecurityConfig에 추가
- Google ID Token 기반 로그인 지원 강화

* feat: 내부 API 경로 및 설정 추가

- `/internal-api/**` 경로 SecurityConfig에 허용 추가 (X-Internal-Secret으로 보호)
- `application.yml`에 INTERNAL_API_SECRET 환경 변수 설정 추가

* feat: 내부 회원 정보 조회 API 추가

- `/internal-api/v1/members/{userId}` 경로 및 컨트롤러 추가
- 요청 헤더(X-Internal-Secret)를 통한 인증 및 내부 API 접근 보호
- 회원 정보(userId, email, nickname) 응답 DTO 구성
- 서비스 호출 및 예외 처리 로직 구현

* feat: 로그 추가 및 예외 처리 로직 개선

- `MemberService`에 SLF4J 로거 추가 및 주요 메서드에 디버그/워닝/에러 로그 추가
- `GlobalExceptionHandler`에서 예상하지 못한 예외 발생 시 스택 트레이스를 포함한 에러 로그 출력
- `InternalMemberController`에 로거 추가 및 요청/응답 과정 로그 기록
- 내부 API 인증 실패 시 워닝 로그 추가 및 검증 흐름 로그 개선

* fix: X-Client-Type 기반 웹/앱 클라이언트 구분 방식 수정

- 기존 `X-Client-Id` 헤더 제거 및 `X-Client-Type` 헤더로 변경
- API Gateway를 통한 클라이언트 타입 기반 웹/앱 구분 로직 개선

* feat: 네이버 소셜로그인 전화번호 저장 및 user-info 응답 추가 (#68)

* feat: 네이버 소셜로그인 시 전화번호 저장 및 user-info 응답 추가

네이버 UserInfo 응답의 mobile 값을 하이픈 제거 형태로 정규화하여
회원 전화번호로 저장하고, user-info 응답(공개/내부)에 내려준다.

- OAuthUser에 phoneNumber 필드 추가 (기본값 null로 타 provider 영향 없음)
- NaverUserInfoResponse.getNormalizedMobile()로 "010-1234-5678" -> "01012345678" 정규화
- NaverOAuthClient가 mobile을 OAuthUser.phoneNumber로 매핑
- MemberEntity.phoneNumber를 var로 변경, createSocialMember 파라미터 및 updatePhoneNumber() 추가
- SocialLoginMemberProcessor: 신규 회원 저장 및 재로그인 시 최신 값으로 갱신
- MemberService.UserInfoResult, UserInfoResponseDto, 내부 UserInfoResponse에 phoneNumber 추가

또한 InternalMemberController의 클래스 레벨 @RequestMapping prefix를 제거하고
메서드에 전체 경로(/internal-api/v1/members/{userId})를 명시하도록 정리한다.

* test: 전화번호 기능 단위 테스트 추가 및 기존 단위 테스트 API 정합성 수정

- NaverUserInfoResponseTest: mobile 정규화 검증 (신규)
- SocialLoginMemberProcessorTest: 신규 저장/재로그인 갱신/mobile 없을 때 유지 검증 (신규)
- MemberServiceTest, MemberEntityTest: phoneNumber 관련 검증 추가
- JwtProviderTest, AuthServiceTest, JwtPropertiesBindingTest: 현재 시그니처
  (Role 제거, memberStatsService 추가, createAccessToken extraClaims)에 맞게 수정

integration 테스트는 별도 수정이 필요해 이 커밋에서 제외한다.

* fix: alpha/prod 쿠키 SameSite를 Strict에서 Lax로 변경 (#70)

소셜 로그인 콜백 등 cross-site에서 시작된 최상위 네비게이션에서
Strict 쿠키가 전송되지 않아 로그인 직후 expired 리다이렉트가 발생.
Lax는 최상위 GET 네비게이션에 쿠키를 허용해 이를 해결한다.

* feat: 회원탈퇴(계정 즉시 삭제) API + SB4·kotest6 테스트 인프라 마이그레이션 (#71)

* test: Spring Boot 4·kotest 6 테스트 인프라 마이그레이션

- kotest-extensions-spring을 kotest 6 좌표(io.kotest:kotest-extensions-spring)로 교체, SpringExtension 인스턴스화 방식으로 전환
- spring-boot-webmvc-test 의존성 추가(SB4에서 @WebMvcTest/@AutoConfigureMockMvc 분리)
- JacksonTimeConfig를 Jackson 3.x(tools.jackson.*) ValueSerializer/Deserializer로 포팅(SB4 Instant 직렬화 복구)
- @WebMvcTest 슬라이스용 인증 헬퍼(WebMvcTestSecurityConfig) 추가 및 컨트롤러 의존성 목 정합성 수정
- 제거된 Role enum 참조를 통합 테스트에서 정리
- springdoc.swagger-ui.path에 기본값 부여(테스트 컨텍스트 로딩 복구)
- 로그인/토큰 재발급 시 app 클라이언트에는 쿠키 미발급(바디 토큰 사용)로 정합성 확보

* feat: 회원탈퇴(계정 즉시 삭제) API 구현

- DELETE /api/v1/auth/members/me 추가(@AuthenticatedApi, 본인 계정 즉시 삭제)
- MemberService.withdraw: 감사기록 저장 후 refresh_token→auth_provider→member 순으로 하드 삭제(FK 안전), 이미 탈퇴 시 멱등 처리
- web 클라이언트는 accessToken/refreshToken 쿠키 만료, app은 토큰 폐기
- MemberWithdrawalAuditEntity 추가(opaqueId·시각·출처만, PII 미포함)
- AuthProviderRepository/RefreshTokenRepository에 deleteByMember 추가
- 기존 무인증 DELETE /api/v1/members/{id} 및 MemberService.delete 제거
- 단위/통합 테스트 추가(삭제 순서·멱등성·web/app 쿠키 분기·미인증 401)

* chore: actuator health 엔드포인트·로깅 설정 추가 및 문서 정리 (#72)

* chore: actuator health 엔드포인트 및 로깅 프로파일 설정 추가

- spring-boot-starter-actuator 의존성 추가, /actuator/health 노출(show-details never, probes 활성화)
- logback: alpha/prod는 JSON 파일+stdout(Alloy 수집), local은 plain text 콘솔 프로파일 분리

* docs: 문서 정리 (옛 가이드 정리 및 README 축소)

- 흩어진 옛 가이드 6종 제거(API Gateway/공통응답/CORS/ENV/OAuth redirect/소셜로그인 스펙)
- README를 핵심 위주로 축소
- GITHUB-ENVIRONMENTS, google-login 가이드 추가

* feat(auth): 토큰 폐기 즉시 반영 · RT 재사용 탐지 · 설정 기본값 정리 (#74)

* fix(security): 임의 사용자 AT를 발급하던 테스트 컨트롤러 제거

인증 없이 opaqueId만 넘기면 유효 서명 AT를 발급하는 /api/public/token이
코드에 남아 있었다(주석에 '나중에 제거 예정'). 게이트웨이가 /api/public/**을
라우팅하지 않아 인터넷에서 직접 닿지는 않으나, 내부망에 닿기만 하면
전 계정 탈취가 된다. SSRF 하나, 컨테이너 침해 하나, 라우트 한 줄이면 실현된다.

컨트롤러 파일을 삭제하고 SecurityConfig의 permitAll 목록에서도
/api/public/**을 제거한다.

이 엔드포인트에 의존하던 통합 테스트 2건을 옮겼다.
- 인증 필요 경로: 토큰을 발급하지 않는 테스트 전용 프로브 컨트롤러
  (src/test 에만 존재하므로 배포 아티팩트에 포함되지 않는다)
- permitAll 경로: /v3/api-docs

작업 중 확인한 것 — permitAll 경로로 /actuator/health를 먼저 시도했으나
테스트 환경에서 health 인디케이터가 DOWN이라 503이 났다. 보안 통과 여부가
아니라 인프라 상태를 재는 테스트가 되어 버려 외부 의존이 없는
/v3/api-docs로 바꿨다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* feat(auth): 토큰 폐기 시각(tokens_invalid_before) 기록

로그아웃 시 RT만 폐기되고 AT는 만료(30분)까지 그대로 유효했다.
세션 전체를 stateful 하게 만들지 않기 위해 사용자당 타임스탬프 하나만 두고
로그아웃·탈퇴 시각을 기록한다.

이 커밋은 값을 쌓기만 한다 — 읽는 쪽이 아직 없어 동작 변화가 없다.
introspect의 폐기 판정은 다음 커밋에서 붙인다.

logout·withdraw 모두 @transactional이라 dirty checking으로 반영된다(save 불필요).

탈퇴는 회원 행 자체를 삭제하므로 이 값이 남지 않는다. 삭제 트랜잭션 커밋
전후의 짧은 창과 게이트웨이 introspect 캐시를 위해 기록하되, 삭제 이후의
판정은 다음 커밋의 '회원 부재 = 폐기' 규칙이 담당한다.

ddl-auto: update(local·alpha·prod 전부)라 마이그레이션 파일은 없다.
기존 행은 NULL(폐기 이력 없음)이 된다. 롤백해도 컬럼만 남고 무해하다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* feat(auth): introspect에 토큰 폐기 확인 추가

로그아웃·탈퇴 후에도 옛 AT가 만료(30분)까지 통과하던 문제를 닫는다.

두 가지를 모두 거른다.
- 회원 행 부재(=탈퇴): 탈퇴는 hard delete라 tokens_invalid_before를 읽을 수
  없다. 행 부재 자체를 폐기 신호로 쓴다.
- iat <= tokens_invalid_before: 로그아웃·탈퇴 이전 발급분.
  iat는 초 단위라 같은 초에 발급된 토큰도 거부하도록 isAfter의 부정으로 비교한다.

설계 문서와 달라진 점 — 설계 초안의 판정은
findTokensInvalidBeforeByOpaqueId(...) ?: return 형태였는데, 탈퇴가 회원 행을
삭제하므로 조회 결과가 null이 되어 그대로 통과했다. 즉 탈퇴 계정의 옛 AT가
살아남는다(설계서 S8 시나리오가 성립하지 않음). 이를 막기 위해 리포지토리
반환형을 Instant?가 아니라 List<Instant?>로 두었다 — 스칼라 프로젝션에서
Instant?를 쓰면 '회원 없음'과 '폐기 이력 없음'이 둘 다 null로 뭉개져
구분할 수 없기 때문이다.

판정은 컨트롤러가 아니라 AuthService에 두었다. AuthController는 리포지토리를
주입받지 않으며, 기존 계층(controller → service → repository)을 지킨다.

silent refresh 경로에는 넣지 않았다. 로그아웃 시 RT가 softDelete되어
findActiveByOpaqueIdAndJti가 먼저 실패하므로 두 방어가 서로 보완한다.

성능: AT 유효 경로에 DB 읽기가 하나 늘어난다(opaque_id unique 인덱스 단일 조회,
idx_member_opaque_id 존재 확인함). 게이트웨이 introspect 캐시(TTL 45초)가
앞단에서 막아 캐시 미스에서만 발생한다. 배포 후 응답시간을 관찰할 것.

테스트 8건 추가(getIssuedAt 3건, 폐기 판정 5건).

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* feat(auth): RT 재사용 탐지 및 토큰 패밀리 폐기

회전(rotation)은 하고 있었으나 탐지가 없었다. 도난된 RT를 공격자가 먼저 쓰면
① 공격자는 새 AT/RT 쌍을 얻고 ② 정상 사용자의 다음 갱신만 실패해
정상 사용자만 로그아웃되며 ③ 도난 사실은 아무도 모른다.

active가 없을 때 '존재한 적 없는 jti'와 '이미 회전되어 폐기된 jti'를 구분한다.
후자면 도난 정황으로 보고 해당 사용자의 RT 전량과 AT(tokens_invalid_before)를
폐기한다. 정상 사용자도 재로그인해야 하지만 계정이 탈취된 상황에서는 그게 옳다.

구분을 위해 리포지토리 메서드 2개를 추가했다 — 기존에는 삭제분 포함 조회와
패밀리 전체 폐기가 없었다.
- findByOpaqueIdAndJtiIncludingDeleted
- softDeleteAllByOpaqueId

보안 알림 채널 통지는 TODO로 남겼다 — 전송 대상 미정(운영 협의 필요).
단순 오류·만료 토큰까지 패밀리를 폐기하지 않도록 테스트로 고정했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(rate-limit): 인증 전 요청이 anonymous 버킷 하나를 공유하던 문제

AT가 만료되어 silent refresh를 타는 introspect 요청은 SecurityContext가
비어 있어 버킷 키가 전부 'anonymous'였다. 60회/분 버킷을 전 사용자가
나눠 쓰는 셈이라, AT 만료 직후 트래픽이 몰리면 무관한 사용자가 429를 맞는다.

인증 전에는 클라이언트 IP를 키로 쓴다. 이 서비스는 게이트웨이 뒤에 있어
remoteAddr이 게이트웨이 IP로 고정되므로 X-Forwarded-For 첫 값을 먼저 본다.
그러지 않으면 다시 버킷 하나를 공유하게 된다.

AT 수명 단축을 검토하게 되면 이 변경이 선행 조건이다 — AT를 짧게 하면
refresh 트래픽이 몇 배로 늘고 그 경로가 바로 이 버킷을 탄다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(security): internal-api 시크릿 확인을 컨트롤러에서 필터로 승격

X-Internal-Secret 확인이 InternalMemberController 메서드 안에 있어,
새 내부 컨트롤러를 추가하면서 빠뜨리면 그대로 노출되는 구조였다.
SecurityConfig에서 해당 경로가 permitAll이기 때문이다.

경로 단위로 강제되도록 OncePerRequestFilter로 올린다.
비교는 상수시간(MessageDigest.isEqual)으로 한다 — 일반 문자열 비교는
앞에서부터 다른 문자를 만나면 즉시 반환하므로 응답 시간 차이로
시크릿을 한 글자씩 추측당할 수 있다.

컨트롤러 내부의 기존 검사는 이중 방어로 남긴다.

작업 중 걸린 것 — KDoc 안에 내부 API 경로를 와일드카드까지 적었더니
'Unclosed comment' 컴파일 에러가 났다. Kotlin 블록 주석은 Java와 달리
중첩되므로 주석 안의 슬래시+별표가 nested comment opener로 읽힌다.
주석에서는 와일드카드를 빼고 표기했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(auth): 계정 병합으로 soft delete 된 계정의 토큰도 폐기

MemberEntity.softDelete()가 실제로 호출되는 곳이 있다 — MemberConnector가
계정 병합 시 흡수된 회원을 soft delete 한다. 행은 남고 isDeleted=true만 된다.

폐기 판정이 isDeleted를 보지 않아, 흡수된 opaqueId의 AT가 만료(30분)까지
계속 통과했다. 탈퇴 hard delete 건과 같은 유형의 구멍이다. 남의 계정 사칭은
아니고 '없어졌어야 할 자기 옛 신원이 더 살아 있는' 상황이지만, 방치하면
다음에 soft delete를 쓰는 코드에서 또 뚫린다.

두 겹으로 막는다.
1. 사건이 일어난 곳에서 기록 — MemberConnector가 softDelete() 직전에
   revokeTokens()를 호출한다.
2. 판정에서도 확인 — assertTokenNotRevoked가 isDeleted를 거른다. 앞으로 1을
   빠뜨리는 코드가 생겨도 이 방어선이 막는다(internal-api 시크릿 검사를
   컨트롤러에서 필터로 올린 것과 같은 논리).

판정에 isDeleted까지 필요해져 조회 반환형을 List<Instant?>에서
MemberRevocationState?(data class)로 바꿨다. null = 회원 없음이 타입으로
드러나고, 판정 로직은 Kotlin에 남아 단위 테스트로 분기를 고정할 수 있다.
조건을 JPQL에 넣는 방식은 로직이 mock 뒤로 숨어 단위 테스트가 무의미해지므로
택하지 않았다.

작업 중 걸린 것 — 처음에 인터페이스 프로젝션으로 만들었더니 isDeleted가 항상
null로 들어와 Kotlin non-null 타입에서 NPE가 났다. Kotlin의 val isDeleted는
게터가 isDeleted()로 컴파일되고 Spring Data는 프로퍼티명을 'deleted'로
해석하는데, 쿼리 별칭은 isDeleted였다. 매칭 실패가 예외가 아니라 조용한 null로
나타난다. 이름 매칭에 의존하지 않는 생성자 표현식으로 바꿨다.

이 실패는 리포지토리를 mock하는 단위 테스트로는 잡히지 않는다. 실제 DB(H2)를
타는 통합 테스트 4건을 함께 추가했고, 그 테스트가 문제를 잡아냈다.
단위 테스트도 병합 케이스 2건을 포함해 7건으로 늘렸다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(auth): RT 재사용 판정에 유예 창 추가 — 동시 요청이 로그아웃을 유발하던 문제

직전 커밋의 재사용 탐지가 정상 사용자를 로그아웃시킬 수 있었다.

경위 — AT 수명은 30분이고 잔여 5분 미만이면 모든 introspect 가 silent refresh 를
탄다. 즉 활성 사용 중 25~30분마다 한 번씩 갱신 구간에 들어간다. 그 구간에
페이지가 API 를 여러 개 동시 호출하면 같은 RT 로 갱신이 겹치고, 하나만 성공한 뒤
나머지는 '이미 회전된 jti'로 보인다. 도난으로 판정하면 토큰 패밀리가 폐기되어
사용자가 재로그인해야 한다.

더 중요한 것은 실패가 조용하지 않다는 점이다. silentRefresh 는 실패 시
clearAuthCookies() 로 쿠키를 지운다. 그래서 '패밀리는 두고 401만' 으로는
로그아웃을 막지 못한다 — 유예 창 안에서는 정상 발급까지 이어가야 한다.

active 가 없을 때를 세 갈래로 나눈다.
1. 존재한 적 없는 jti        → 401, 패밀리 유지
2. 유예 창 내에 회전된 jti   → 정상 발급 (사용자에게 보이지 않음)
3. 그보다 오래된 jti         → 도난 정황: 패밀리 폐기 + 401

유예는 app.auth.refresh-reuse-grace-seconds (기본 30초)로 뺐다. 운영 로그를 보고
env 만 고쳐 조정할 수 있다. 0 으로 두면 유예 없이 동작한다.

대가 — 유예 창 안에서는 도난 토큰도 통과한다. 다만 공격자가 정상 회전 직후
몇 초 안에 써야 하므로 창이 매우 좁고, 실제 도난은 대개 한참 뒤에 나타난다.
정상 사용자가 주기적으로 튕기는 쪽이 더 큰 피해라고 판단했다.

테스트 3건으로 세 갈래를 고정했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* feat(auth): RT 재사용 감지 시 Google Chat 으로 보안 알림 전송

탐지는 되는데 log.warn 에만 남아 아무도 모르는 상태였다. 계정 탈취 정황이므로
운영 채널로 알린다.

알림은 절대 본 요청을 방해하지 않도록 만들었다.
- @async — 인증 요청 스레드를 잡지 않는다. 동기로 부르면 채널이 느릴 때
  로그인·갱신이 함께 느려지고, 호출부가 @transactional 이라 DB 트랜잭션이
  네트워크 왕복만큼 열린 채 유지된다.
- 어떤 예외도 밖으로 내보내지 않는다. 알림 실패가 인증 실패가 되면 안 된다.
- 타임아웃은 기존 RestClient 빈이 갖고 있다(연결 3초 / 응답 3초).

auth-api 는 이미 네이버·구글·카카오 OAuth 로 외부 호출을 하고 있어 egress 가
새로 열리는 것은 아니다.

메시지에 자격증명을 담지 않는다. opaqueId 와 jti(토큰 값이 아님), 시각만 싣는다.
웹훅 URL 자체가 시크릿이라 로그에도 남기지 않는다.
설정이 없으면 전송을 건너뛰고 경고만 남긴다 — env 없이도 기동한다.

알림은 도난 정황일 때만 보낸다. 유예 창 안의 동시 요청까지 울리면 신호가
오염되어 진짜 도난을 놓친다. 테스트로 고정했다.

전송 검증은 목킹이 아니라 로컬 스텁 HTTP 서버로 했다. 실제로 어떤 본문이 어떤
헤더로 나가는지 확인해야 Google Chat 의 {"text": ...} 계약을 고정할 수 있다.
5xx 응답·연결 불가·URL 미설정 세 경우 모두 예외가 새지 않는 것도 확인했다.

설정 2개 추가:
- app.auth.refresh-reuse-grace-seconds (AUTH_REFRESH_REUSE_GRACE_SECONDS, 기본 30)
- app.alert.google-chat-webhook-url (GOOGLE_CHAT_WEBHOOK_URL, 기본 비어 있음)

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* refactor(alert): 보안 알림 env 이름을 SECURITY_ALERT_CHAT_WEBHOOK_URL 로 분리

보안 전용 채널을 따로 쓰기로 하면서 GOOGLE_CHAT_WEBHOOK_URL 이라는 이름이
문제가 됐다. wedding 저장소가 같은 이름을 일반 운영 알림(Sentry 변환·캐시 워밍)에
쓰고 있어서, 이름이 같으면 저장소 간에 값을 옮기다가 보안 경보가 일반 채널로
새거나 그 반대가 되기 쉽다. 같은 이름이 저장소마다 다른 채널을 가리키는 상태는
설정 실수를 부른다.

app.alert.security-chat-webhook-url / SECURITY_ALERT_CHAT_WEBHOOK_URL 로 바꿨다.
이름만으로 어느 채널인지 드러난다.

미설정 시 경고 로그에도 정확한 변수명을 넣어, 알림이 안 올 때 무엇을 설정해야
하는지 로그만 보고 알 수 있게 했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(alert): 보안 알림 제목에 환경 표시

alpha 와 prod 가 같은 웹훅 채널을 공유하기로 해서, 어느 환경 사고인지
한눈에 구분돼야 한다. 기존에는 환경이 본문 둘째 줄에 있어 목록에서
훑을 때 보이지 않았다.

제목 맨 앞으로 옮기고 대문자로 표시한다.
  🚨 *[PROD] RT 재사용 감지 — 계정 탈취 정황*
  🚨 *[ALPHA] RT 재사용 감지 — 계정 탈취 정황*

환경값은 spring.profiles.active 를 그대로 쓴다. 배포 워크플로가 main → prod,
dev → alpha 로 넣어주므로 별도 설정이 필요 없다.

테스트에 환경 표시 검증을 추가했다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* refactor(config): 비밀·환경 식별 설정의 하드코딩 기본값 제거

기본값이 있으면 설정 주입이 빠져도 애플리케이션이 잘못된 값으로 조용히 뜬다.
없으면 기동 자체가 실패해 배포 시점에 바로 드러난다. 후자가 원인 추적이 쉽다.

제거한 것 (전부 공개돼선 안 되거나 환경마다 달라야 하는 값)
- JWT_SECRET: 기본값이 'jwt-secret' 이었다. env 주입이 빠지면 코드에 적힌 알려진
  키로 토큰을 서명·검증하게 된다 — 저장소를 본 사람이면 누구나 토큰을 위조할 수 있다.
  이 파일의 access-exp·refresh-exp 는 이미 기본값이 없어 일관성도 맞지 않았다.
- DB_PASSWORD: 기본값 'postgres'. 자격증명은 코드에 두지 않는다.
- INTERNAL_LOGGING_SECRET: 기본값 'default-secret'.
- SECURITY_ALERT_CHAT_WEBHOOK_URL: 웹훅 URL 자체가 시크릿이다.

유지한 것 — 공개돼도 문제없고 누락 시 조용히 잘못 동작하지 않는 값.
포트·프로파일·로그레벨·타임존·스웨거 경로·DB 호스트/포트/이름/사용자,
그리고 순수 튜닝 상수(캐시 TTL, RT 재사용 유예 30초).

테스트를 자립적으로 만들었다. 이전에는 gitignore 된 로컬 .env 에 값이 있어야
통과했다 — 새로 클론한 개발자나 CI 에서는 재현되지 않는 실패가 난다.
SpringBootTest 4곳에 필수 설정을 명시했고, .env 를 치운 상태에서 115건 통과를
확인했다. 이제 그 목록이 '기동에 필요한 설정'의 문서 역할을 한다.

배포 전 필요 — 두 GitHub Environment(production/alpha)의 AUTH_BE_ENV_FILE 에
INTERNAL_LOGGING_SECRET 과 SECURITY_ALERT_CHAT_WEBHOOK_URL 이 있어야 한다.
없으면 기동에 실패한다(의도된 동작).

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL

* fix(config): main 병합 시 중복 생성된 management 블록 제거

dev 와 main 의 application.yml 이 같은 management 블록을 서로 다른 위치에
갖고 있어, git 이 두 추가를 각각 살려 최상위 키가 중복됐다.
YAML 은 중복 키를 허용하지 않아 Spring 컨텍스트 로딩이 실패한다
(SpringBootTest 전부 IllegalStateException).

두 블록의 내용이 완전히 동일하므로 하나만 남긴다.

Claude-Session: https://claude.ai/code/session_01YDhB7hbzTSq7cSKyPJoFNL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant