diff --git a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt b/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt deleted file mode 100644 index 9b3e397..0000000 --- a/src/main/kotlin/com/wq/auth/api/controller/TestSecurityController.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.wq.auth.api.controller - -import com.wq.auth.security.jwt.JwtProvider -import com.wq.auth.security.annotation.AuthenticatedApi -import com.wq.auth.security.annotation.PublicApi -import com.wq.auth.web.common.response.CommonResponse -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -/** - * Security 테스트용 컨트롤러 - * 개발 및 테스트 환경에서 JWT 인증 동작을 확인하기 위한 엔드포인트 제공 - * todo: 나중에 제거 예정 - */ -@RestController -class TestSecurityController( - private val jwtProvider: JwtProvider -) { - - @PublicApi - @GetMapping("/api/public/test") - fun publicTestEndpoint(): CommonResponse> { - val data = mapOf( - "endpoint" to "/api/public/test", - "accessLevel" to "PUBLIC", - "description" to "누구나 접근 가능한 공개 API" - ) - return CommonResponse.success("공개 API 접근 성공", data) - } - - @AuthenticatedApi - @GetMapping("/api/test") - fun authenticatedEndpoint(): CommonResponse> { - val data = mapOf( - "endpoint" to "/api/test", - "accessLevel" to "AUTHENTICATED", - "description" to "로그인한 사용자만 접근 가능한 API" - ) - return CommonResponse.success("인증된 사용자 API 접근 성공", data) - } - - @PublicApi - @GetMapping("/api/public/token") - fun generateTestToken( - @RequestParam(defaultValue = "550e8400-e29b-41d4-a716-446655440000") opaqueId: String, - ): CommonResponse> { - val token = jwtProvider.createAccessToken(opaqueId) - val data = mapOf( - "token" to token, - "opaqueId" to opaqueId, - "usage" to "Authorization: Bearer $token" - ) - return CommonResponse.success("JWT 토큰 발급 성공", data) - } -} diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt index d628580..95da1e0 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/AuthController.kt @@ -351,8 +351,12 @@ class AuthController( val deviceId = runCatching { jwtProvider.getClaimsEvenIfExpired(token)["deviceId"] as? String }.getOrNull() silentRefresh(request, response, isApp, deviceId) } else { - // AT 유효 - jwtProvider.getOpaqueId(token) + // AT 유효 — 서명은 맞지만 로그아웃·탈퇴로 폐기됐을 수 있다. + // 폐기 확인은 여기서만 한다. silent refresh 경로는 RT 가 softDelete 되어 + // findActiveByOpaqueIdAndJti 가 먼저 실패하므로 중복 확인이 불필요하다. + val id = jwtProvider.getOpaqueId(token) + authService.assertTokenNotRevoked(token, id) + id } } diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt index e3222a6..1f14950 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt @@ -15,10 +15,13 @@ import com.wq.auth.api.domain.member.error.MemberExceptionCode import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.wq.auth.shared.alert.SecurityAlertNotifier import com.wq.auth.shared.utils.NicknameGenerator import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Duration import java.time.Instant @Service @@ -31,6 +34,17 @@ class AuthService( private val nicknameGenerator: NicknameGenerator, private val memberConnector: MemberConnector, private val memberStatsService: MemberStatsService, + private val securityAlertNotifier: SecurityAlertNotifier, + + /** + * RT 재사용 유예 창(초). + * + * 이 시간 안에 회전된 RT 가 다시 오면 **도난이 아니라 동시 요청·재시도**로 본다. + * 0 으로 두면 유예 없이 즉시 도난으로 판정한다(정상 사용자 로그아웃 위험). + * 운영 로그를 보고 조정할 수 있도록 설정값으로 뺐다. + */ + @Value("\${app.auth.refresh-reuse-grace-seconds:30}") + private val refreshReuseGraceSeconds: Long = 30, ) { private val log = KotlinLogging.logger {} @@ -128,6 +142,43 @@ class AuthService( log.info { "이메일 연동 완료: $currentOpaqueId -> ${request.email}" } } + /** + * 폐기된 토큰이면 [JwtException]을 던집니다. 유효하면 조용히 반환합니다. + * + * 세 가지를 거릅니다. 서명이 유효해도 그 신원이 이미 없어졌을 수 있기 때문입니다. + * ① **회원 행이 없음** = 탈퇴(hard delete)한 계정. `tokens_invalid_before` 를 읽을 + * 수조차 없으므로 행 부재 자체를 폐기 신호로 씁니다. + * ② **soft delete 된 계정** = 계정 병합으로 흡수된 회원([MemberConnector]). + * 행은 남지만 그 신원으로는 더 이상 로그인할 수 없으므로 토큰도 무효여야 합니다. + * ③ **iat <= tokens_invalid_before** = 로그아웃·탈퇴·RT 재사용 탐지 이전 발급분. + * + * ②는 병합 시점에도 [MemberEntity.revokeTokens] 로 기록하지만, 여기서도 확인합니다. + * 앞으로 soft delete 를 쓰는 코드가 기록을 빠뜨려도 이 방어선이 막습니다. + * + * iat 는 초 단위 정밀도라 같은 초에 발급된 토큰도 거부해야 합니다 — + * 그래서 `isAfter` 의 부정으로 비교합니다. + * + * **성능** — introspect 의 AT 유효 경로에서 호출되므로 DB 읽기가 하나 추가됩니다. + * opaque_id unique 인덱스 단일 조회이고 컬럼 두 개만 읽습니다. 게이트웨이 + * introspect 캐시가 앞단에서 대부분을 막아 실제로는 캐시 미스에서만 발생합니다. + */ + fun assertTokenNotRevoked(token: String, opaqueId: String) { + val state = memberRepository.findRevocationStateByOpaqueId(opaqueId) + if (state == null) { + log.info { "폐기된 토큰: 회원 없음(탈퇴) opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + if (state.isDeleted) { + log.info { "폐기된 토큰: 삭제된 계정(병합 등) opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + val invalidBefore = state.tokensInvalidBefore ?: return // 폐기 이력 없음 — 정상 + if (!jwtProvider.getIssuedAt(token).isAfter(invalidBefore)) { + log.info { "폐기된 토큰: 폐기 시각 이전 발급 opaqueId=$opaqueId" } + throw JwtException(JwtExceptionCode.EXPIRED) + } + } + @Transactional fun logout(refreshToken: String?) { if (refreshToken.isNullOrBlank()) { @@ -140,6 +191,10 @@ class AuthService( val opaqueId = jwtProvider.getOpaqueId(refreshToken) val jti = jwtProvider.getJti(refreshToken) refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) + // RT 만 지우면 AT 는 만료(30분)까지 그대로 유효하다. + // 폐기 시각을 남겨 introspect 가 옛 AT 를 거부하게 한다. + // @Transactional 이라 dirty checking 으로 반영된다 — save() 불필요. + memberRepository.findByOpaqueId(opaqueId).ifPresent { it.revokeTokens() } } catch (e: JwtException) { log.info { "만료된 refreshToken으로 로그아웃: ${e.message}" } } catch (ex: Exception) { @@ -154,8 +209,42 @@ class AuthService( val jti = jwtProvider.getJti(refreshToken) val opaqueId = jwtProvider.getOpaqueId(refreshToken) - refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti) - ?: throw JwtException(JwtExceptionCode.MALFORMED) + // active 가 없는 이유가 세 가지다. 구분해야 도난만 골라낼 수 있다. + if (refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti) == null) { + val used = refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti) + // ① 존재한 적 없는 jti — 잘못된 토큰. 패밀리는 건드리지 않는다. + ?: throw JwtException(JwtExceptionCode.MALFORMED) + + val rotatedAt = used.deletedAt + val withinGrace = rotatedAt != null && + Duration.between(rotatedAt, Instant.now()).seconds <= refreshReuseGraceSeconds + + if (withinGrace) { + // ② 방금 회전된 jti — 동시 요청이나 네트워크 재시도다. + // + // AT 잔여 5분 미만이면 모든 introspect 가 갱신을 타는데(AT 수명 30분), + // 그 구간에 페이지가 API 를 여러 개 동시 호출하면 같은 RT 로 갱신이 겹친다. + // 여기서 실패시키면 silentRefresh 가 쿠키를 지워(clearAuthCookies) + // **정상 사용자가 로그아웃된다.** 그래서 아래 정상 발급 경로로 이어간다. + // + // 대가: 유예 창 안에서는 도난 토큰도 통과한다. 다만 공격자가 정상 회전 + // 직후 몇 초 안에 써야 하므로 창이 매우 좁고, 그 대가로 정상 사용자가 + // 주기적으로 튕기는 문제를 막는다. + log.info { "RT 재사용(유예 창 내) — 동시 요청·재시도로 판단: opaqueId=$opaqueId jti=$jti" } + } else { + // ③ 한참 전에 폐기된 jti 가 다시 나타났다 = 도난 정황. + // 회전만 하고 탐지가 없으면, 공격자가 먼저 쓴 경우 공격자는 새 토큰 쌍을 얻고 + // 정상 사용자만 로그아웃되며 아무도 도난 사실을 모른다. + // 계정이 탈취된 상황이므로 정상 사용자도 재로그인시키는 것이 옳다. + log.warn { "RT 재사용 감지(도난 정황): opaqueId=$opaqueId jti=$jti rotatedAt=$rotatedAt" } + refreshTokenRepository.softDeleteAllByOpaqueId(opaqueId, Instant.now()) + memberRepository.findByOpaqueId(opaqueId).ifPresent { it.revokeTokens() } + // 로그에만 남기면 아무도 모른다. 계정 탈취 정황이므로 운영 채널로 알린다. + // 비동기이고 실패해도 예외를 내보내지 않으므로 이 경로를 지연시키지 않는다. + securityAlertNotifier.refreshTokenReuseDetected(opaqueId, jti, rotatedAt) + throw JwtException(JwtExceptionCode.MALFORMED) + } + } if (jwtProvider.getRefreshTokenExpiredAt(refreshToken).isBefore(Instant.now())) { refreshTokenRepository.softDeleteByOpaqueIdAndJti(opaqueId, jti, Instant.now()) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt index 2f63fb9..1f3d5b6 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/MemberConnector.kt @@ -125,6 +125,9 @@ class MemberConnector( } // 연동된 회원을 soft delete 처리 + // 이 신원으로는 더 이상 로그인할 수 없으므로 이미 발급된 토큰도 함께 폐기한다. + // (기록하지 않으면 흡수된 opaqueId 의 AT 가 만료까지 계속 통과한다) + linkedMember.revokeTokens() linkedMember.softDelete() memberRepository.save(linkedMember) diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt index bb5ade1..95afe3b 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/RefreshTokenRepository.kt @@ -34,4 +34,26 @@ interface RefreshTokenRepository : JpaRepository { @Transactional fun deleteByMember(member: MemberEntity) + /** + * soft delete 된 것까지 포함해 조회합니다. + * + * "존재한 적 없는 jti"와 "이미 회전되어 폐기된 jti"를 구분하기 위한 것으로, + * 후자는 **RT 도난 정황**입니다. findActiveByOpaqueIdAndJti 만으로는 둘 다 null 이라 + * 구분할 수 없습니다. + */ + @Query("SELECT r FROM RefreshTokenEntity r WHERE r.member.opaqueId = :opaqueId AND r.jti = :jti") + fun findByOpaqueIdAndJtiIncludingDeleted( + @Param("opaqueId") opaqueId: String, + @Param("jti") jti: String + ): RefreshTokenEntity? + + /** 해당 사용자의 살아 있는 RT 를 모두 폐기합니다 (토큰 패밀리 전체 폐기). */ + @Modifying + @Transactional + @Query("UPDATE RefreshTokenEntity r SET r.deletedAt = :deletedAt WHERE r.member.opaqueId = :opaqueId AND r.deletedAt IS NULL") + fun softDeleteAllByOpaqueId( + @Param("opaqueId") opaqueId: String, + @Param("deletedAt") deletedAt: Instant + ): Int + } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt index 4787467..747e7f8 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.Instant import java.util.* @Repository @@ -27,4 +28,28 @@ interface MemberRepository : JpaRepository { ): Optional fun findByOpaqueIdAndIsDeletedFalse(opaqueId: String): Optional + + /** + * 토큰 폐기 판정에 필요한 상태만 조회합니다. + * + * 세 가지 상황을 구분해야 합니다. + * ① **결과가 `null`** — 회원 행이 없음 = 탈퇴(hard delete)한 계정 + * ② **`isDeleted = true`** — soft delete 된 계정 (계정 병합으로 흡수된 회원) + * ③ **`tokensInvalidBefore`** — 로그아웃·탈퇴·RT 재사용 탐지 시각 + * + * 스칼라 값 하나만 뽑으면 ①과 "폐기 이력 없음"이 둘 다 `null` 로 뭉개져 구분할 수 없습니다. + * 판정 로직은 [MemberRevocationState] 를 받아 서비스에서 수행합니다 — + * 조건을 JPQL 에 넣으면 단위 테스트가 분기를 검증할 수 없기 때문입니다. + * + * 엔티티를 통째로 로딩하지 않는 이유는 이 쿼리가 introspect 핫패스에서 돌기 때문입니다. + * opaque_id 에는 unique 인덱스(idx_member_opaque_id)가 있습니다. + */ + @Query( + """ + select new com.wq.auth.api.domain.member.MemberRevocationState(m.isDeleted, m.tokensInvalidBefore) + from MemberEntity m + where m.opaqueId = :opaqueId + """ + ) + fun findRevocationStateByOpaqueId(@Param("opaqueId") opaqueId: String): MemberRevocationState? } \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt new file mode 100644 index 0000000..62e7162 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberRevocationState.kt @@ -0,0 +1,28 @@ +package com.wq.auth.api.domain.member + +import java.time.Instant + +/** + * 토큰 폐기 판정에 필요한 최소 상태. + * + * **엔티티를 통째로 로딩하지 않는 이유** — 이 조회는 introspect 핫패스에서 돈다. + * 필요한 것은 두 값뿐이다. + * + * **조회 결과가 `null` 이라는 것 자체가 신호다** — 회원 행이 없다는 뜻이고 + * 탈퇴(hard delete)한 계정을 의미한다. 스칼라 값 하나만 뽑으면 "회원 없음"과 + * "폐기 이력 없음"이 둘 다 `null` 로 뭉개져 구분할 수 없다. + * + * **인터페이스 프로젝션이 아니라 생성자 표현식(`select new ...`)을 쓴다.** + * 인터페이스 프로젝션은 별칭과 게터 이름을 매칭하는데, Kotlin 의 `is` 접두사 프로퍼티는 + * `isDeleted()` 게터가 되어 Spring Data 가 프로퍼티명을 `deleted` 로 해석한다. + * 별칭을 `isDeleted` 로 두면 매칭에 실패해 **조용히 null 이 들어오고**, + * 그 결과 Kotlin non-null 타입에서 NPE 가 난다(실제로 겪었다). + * 생성자 표현식은 위치 기반이라 이런 이름 매칭 실패가 원천적으로 없다. + */ +data class MemberRevocationState( + /** soft delete 여부. 계정 병합으로 흡수된 회원이 여기에 해당한다. */ + val isDeleted: Boolean, + + /** 이 시각 이전(=이하)에 발급된 토큰은 폐기된 것으로 본다. null 이면 폐기 이력 없음. */ + val tokensInvalidBefore: Instant?, +) diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt index b980b4b..920c0f4 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/MemberService.kt @@ -98,6 +98,11 @@ class MemberService( ) ) + // 회원 행은 바로 아래에서 삭제되지만, 삭제 트랜잭션 커밋 전후의 짧은 창과 + // 게이트웨이 introspect 캐시 때문에 폐기 시각도 함께 남긴다. + // 삭제된 이후의 판정은 introspect 의 "회원 부재 = 폐기" 규칙이 담당한다. + member.revokeTokens() + refreshTokenRepository.deleteByMember(member) authProviderRepository.deleteByMember(member) memberRepository.delete(member) diff --git a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt index 1454c53..872f04e 100644 --- a/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt +++ b/src/main/kotlin/com/wq/auth/api/domain/member/entity/MemberEntity.kt @@ -3,6 +3,7 @@ package com.wq.auth.api.domain.member.entity import com.wq.auth.shared.entity.BaseEntity import com.github.f4b6a3.uuid.UuidCreator import jakarta.persistence.* +import java.time.Instant import java.time.LocalDateTime @Entity @@ -41,6 +42,16 @@ open class MemberEntity protected constructor( @Column(name = "is_deleted", nullable = false) var isDeleted: Boolean = false, + /** + * 이 시각 이전(=이하)에 발급된 토큰은 폐기된 것으로 본다. + * 로그아웃·탈퇴·RT 재사용 탐지 시 기록한다. null 이면 폐기 이력 없음. + * + * 세션 전체를 stateful 하게 관리하지 않기 위해 사용자당 타임스탬프 하나만 둔다. + * 실제 판정은 introspect 가 토큰의 iat 와 비교해 수행한다. + */ + @Column(name = "tokens_invalid_before", nullable = true) + var tokensInvalidBefore: Instant? = null, + ) : BaseEntity() { companion object { @@ -113,6 +124,16 @@ open class MemberEntity protected constructor( this.isDeleted = true } + /** + * 이 회원이 지금까지 발급받은 토큰을 모두 폐기 대상으로 표시합니다. + * + * AT 는 만료(30분)까지 서명이 유효하므로 RT 폐기만으로는 즉시 로그아웃이 되지 않습니다. + * 이 시각을 남겨 두면 introspect 가 iat 를 비교해 옛 AT 를 거부합니다. + */ + fun revokeTokens(at: Instant = Instant.now()) { + this.tokensInvalidBefore = at + } + override fun toString(): String { return "MemberEntity(id=$id, opaqueId='$opaqueId', nickname='$nickname')" } diff --git a/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt b/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt new file mode 100644 index 0000000..93d2b3e --- /dev/null +++ b/src/main/kotlin/com/wq/auth/security/InternalSecretFilter.kt @@ -0,0 +1,46 @@ +package com.wq.auth.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.security.MessageDigest + +/** + * `/internal-api/` 하위 전체를 `X-Internal-Secret` 헤더로 강제 보호한다. + * + * 기존에는 이 확인이 [com.wq.auth.api.controller.internal.InternalMemberController] 의 + * **메서드 안에** 있었다. 컨트롤러가 하나뿐인 지금은 문제가 없지만, 새 내부 컨트롤러를 + * 추가하면서 확인을 빠뜨리면 그대로 노출되는 구조다. SecurityConfig 에서 + * `/internal-api/` 하위 전체는 permitAll 이기 때문이다. + * + * 경로 단위로 강제되도록 필터로 올린다. 컨트롤러 내부의 기존 검사는 이중 방어로 남긴다. + */ +@Component +class InternalSecretFilter( + @Value("\${app.internal.secret}") private val secret: String, +) : OncePerRequestFilter() { + + override fun shouldNotFilter(request: HttpServletRequest): Boolean = + !request.requestURI.startsWith("/internal-api/") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val provided = request.getHeader("X-Internal-Secret")?.toByteArray() ?: ByteArray(0) + // 문자열 != 가 아니라 상수시간 비교를 쓴다. + // 일반 비교는 앞에서부터 다른 문자를 만나면 즉시 반환하므로, + // 응답 시간 차이로 시크릿을 한 글자씩 추측당할 수 있다. + if (!MessageDigest.isEqual(provided, secret.toByteArray())) { + logger.warn("[internal-api] X-Internal-Secret 불일치 - ${request.requestURI}") + response.status = HttpStatus.FORBIDDEN.value() + return + } + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt index f14f0aa..12311cc 100644 --- a/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt +++ b/src/main/kotlin/com/wq/auth/security/jwt/JwtProvider.kt @@ -82,6 +82,24 @@ class JwtProvider( .payload .id + /** + * JWT 토큰에서 발급 시각(iat)을 추출합니다. + * + * iat 는 JWT 표준상 **초 단위** 정밀도입니다. 폐기 판정에서 이 점이 중요합니다 — + * 같은 초에 발급된 토큰까지 거부하려면 호출부가 `iat > invalidBefore` 가 아니라 + * 그 부정(`!isAfter`)으로 비교해야 합니다. + * + * 서명을 검증하므로 위조된 토큰이면 예외를 던집니다. + * + * @param token 대상 JWT 토큰 + * @return 발급 시각 + */ + fun getIssuedAt(token: String): Instant = + Jwts.parser().verifyWith(key) + .build().parseSignedClaims(token) + .payload + .issuedAt.toInstant() + /** * JWT 토큰에서 모든 클레임을 추출합니다. * @param token 대상 JWT 토큰 diff --git a/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt b/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt new file mode 100644 index 0000000..1752100 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/shared/alert/SecurityAlertNotifier.kt @@ -0,0 +1,81 @@ +package com.wq.auth.shared.alert + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.time.Instant + +/** + * 보안 이벤트를 운영 알림 채널(Google Chat)로 보낸다. + * + * **설계 원칙 — 알림은 절대 본 요청을 방해하지 않는다.** + * - `@Async` — 인증 요청 스레드를 잡지 않는다. 동기로 부르면 채널이 느릴 때 + * 사용자의 로그인·갱신이 함께 느려지고, 호출부가 `@Transactional` 이라 + * **DB 트랜잭션이 네트워크 왕복만큼 열린 채로 유지된다.** + * - **어떤 예외도 밖으로 내보내지 않는다.** 알림 실패가 인증 실패가 되면 안 된다. + * - 타임아웃은 주입되는 [RestClient] 빈이 갖고 있다(연결 3초 / 응답 3초). + * + * **웹훅 URL 은 그 자체가 시크릿이다.** 아는 사람은 누구나 그 채널에 글을 쓸 수 있다. + * 로그에 찍지 않으며, 비어 있으면 전송을 건너뛰고 경고만 남긴다(설정 없이도 기동한다). + * + * **메시지에 자격증명을 넣지 않는다.** 토큰 값·시크릿은 담지 않는다. + * 알림 채널로 자격증명이 새면 알림이 사고가 된다. + */ +@Component +class SecurityAlertNotifier( + private val restClient: RestClient, + @Value("\${app.alert.security-chat-webhook-url}") + private val webhookUrl: String, + @Value("\${spring.profiles.active:local}") + private val environment: String, +) { + private val log = KotlinLogging.logger {} + + /** + * RT 재사용(도난 정황) 통지. + * + * @param opaqueId 대상 사용자 식별자 (UUID — 이메일·이름 등은 담지 않는다) + * @param jti 재사용된 RefreshToken 의 식별자 (토큰 값이 아니다) + * @param rotatedAt 그 jti 가 회전(폐기)된 시각. null 이면 알 수 없음 + */ + fun refreshTokenReuseDetected(opaqueId: String, jti: String, rotatedAt: Instant?) { + val text = buildString { + // 환경을 제목 맨 앞에 둔다. alpha 와 prod 가 같은 채널을 공유하므로 + // 목록에서 훑을 때 어느 환경 사고인지 즉시 구분돼야 한다. + append("🚨 *[${environment.uppercase()}] RT 재사용 감지 — 계정 탈취 정황*\n") + append("사용자: `$opaqueId`\n") + append("재사용된 jti: `$jti`\n") + append("해당 jti 회전 시각: `${rotatedAt ?: "알 수 없음"}`\n") + append("감지 시각: `${Instant.now()}`\n") + append("\n") + append("이미 자동 조치됨 — 해당 계정의 RefreshToken 전량 폐기 + AccessToken 무효화. ") + append("사용자는 재로그인이 필요합니다.\n") + append("확인할 것: 같은 사용자에게 반복되는지, *여러 사용자에게 동시다발인지*. ") + append("후자면 토큰 유출 경로 자체를 의심해야 합니다.") + } + send(text) + } + + /** 전송 실패는 로그로만 남긴다. 호출부로 예외가 나가지 않는다. */ + @Async + fun send(text: String) { + if (webhookUrl.isBlank()) { + log.warn { "보안 알림 미전송 — app.alert.security-chat-webhook-url(SECURITY_ALERT_CHAT_WEBHOOK_URL)이 설정되지 않았습니다." } + return + } + try { + restClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("text" to text)) + .retrieve() + .toBodilessEntity() + } catch (e: Exception) { + // URL 은 시크릿이므로 로그에 남기지 않는다. + log.error(e) { "보안 알림 전송 실패: ${e.message}" } + } + } +} diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index f59b124..15eaa7f 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -51,13 +51,14 @@ class SecurityConfig( auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용 // 공개 엔드포인트 (인증 불필요) - .requestMatchers("/internal-api/**").permitAll() // 서비스 간 내부 통신 (X-Internal-Secret으로 보호) + // 서비스 간 내부 통신. Spring Security 는 통과시키되, + // InternalSecretFilter 가 경로 단위로 X-Internal-Secret 을 강제한다. + .requestMatchers("/internal-api/**").permitAll() .requestMatchers( "/api/v1/auth/members/email-login", // 이메일 로그인 "/api/v1/auth/email/request", // 이메일 인증 코드 요청 "/api/v1/auth/email/verify", // 이메일 인증 코드 검증 (로그인 전 호출) "/api/v1/auth/members/refresh", // 액세스 토큰 재발급 - "/api/public/**", // 공개 API "/api/v1/auth/*/login", // 소셜 로그인 API (V1) "/api/v1/auth/google/login/app", // 안드로이드 앱 전용 Google ID Token 로그인 "/api/v2/auth/*/login", // 소셜 로그인 API (V2) diff --git a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt index fd69783..671f1f5 100644 --- a/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt +++ b/src/main/kotlin/com/wq/auth/shared/rateLimiter/RateLimiterInterceptor.kt @@ -37,9 +37,14 @@ class RateLimiterInterceptor( val rateLimit = handler.getMethodAnnotation(RateLimit::class.java) ?: return true - // 유저 OpaqueId 가져오기 - val userOpaqueId = SecurityContextHolder.getContext() - .authentication?.name ?: "anonymous" + // 버킷 키. 인증된 요청은 opaqueId 를 쓴다. + // + // 인증 전 요청(만료된 AT 로 introspect → silent refresh)은 SecurityContext 가 비어 있다. + // 그대로 "anonymous" 를 쓰면 전 사용자가 버킷 하나(60회/분)를 공유하게 되어, + // AT 만료 직후 트래픽이 몰리면 무관한 사용자들이 429 를 맞는다. 그래서 IP 로 나눈다. + val bucketKey = SecurityContextHolder.getContext() + .authentication?.name + ?: clientIpOf(request) // Duration 변환. 기본은 분 val duration = when (rateLimit.timeUnit) { @@ -49,7 +54,7 @@ class RateLimiterInterceptor( else -> Duration.ofMinutes(rateLimit.duration) } - return if (rateLimiter.allowRequest(userOpaqueId, rateLimit.limit, duration)) { + return if (rateLimiter.allowRequest(bucketKey, rateLimit.limit, duration)) { true } else { //429 @@ -65,8 +70,23 @@ class RateLimiterInterceptor( response.writer.write(jsonMapper.writeValueAsString(failResponse)) - log.info{"Rate limit exceeded: userOpaqueId=$userOpaqueId, endpoint=${request.requestURI}"} + log.info{"Rate limit exceeded: bucketKey=$bucketKey, endpoint=${request.requestURI}"} false } } + + /** + * 인증 전 요청의 레이트리밋 버킷 키. + * + * 이 서비스는 게이트웨이 뒤에 있어 remoteAddr 이 게이트웨이 IP 로 고정된다. + * 그러면 다시 전 사용자가 버킷 하나를 공유하게 되므로 X-Forwarded-For 를 먼저 본다. + */ + private fun clientIpOf(request: HttpServletRequest): String = + request.getHeader("X-Forwarded-For") + ?.split(",") + ?.firstOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: request.remoteAddr + ?: "anonymous" } diff --git a/src/main/resources/application-jwt.yml b/src/main/resources/application-jwt.yml index 9d4ecd3..f6f8ac0 100644 --- a/src/main/resources/application-jwt.yml +++ b/src/main/resources/application-jwt.yml @@ -1,4 +1,4 @@ jwt: - secret: ${JWT_SECRET:jwt-secret} + secret: ${JWT_SECRET} access-exp: ${JWT_ACCESS_TOKEN_EXPIRATION} refresh-exp: ${JWT_REFRESH_TOKEN_EXPIRATION} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 07afccf..ac9ff9f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:authdb} driver-class-name: org.postgresql.Driver username: ${DB_USERNAME:postgres} - password: ${DB_PASSWORD:postgres} + password: ${DB_PASSWORD} jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect @@ -54,6 +54,19 @@ app: domain: ${APP_COOKIE_DOMAIN:localhost} internal: secret: ${INTERNAL_API_SECRET} + auth: + # RT 재사용 유예 창(초). 이 시간 안에 회전된 RT 가 다시 오면 동시 요청·재시도로 본다. + # 0 이면 유예 없음(정상 사용자가 로그아웃될 수 있다). + refresh-reuse-grace-seconds: ${AUTH_REFRESH_REUSE_GRACE_SECONDS:30} + alert: + # 보안 이벤트 전용 알림 채널. 비어 있으면 전송을 건너뛰고 경고 로그만 남긴다. + # + # 변수 이름을 GOOGLE_CHAT_WEBHOOK_URL 로 두지 않는다. wedding 저장소가 같은 이름을 + # 일반 운영 알림(Sentry 변환·캐시 워밍)에 쓰고 있어서, 이름이 같으면 저장소 간에 + # 값을 옮기다가 보안 경보가 일반 채널로 새거나 그 반대가 되기 쉽다. + # + # 이 URL 자체가 시크릿이다 — 아는 사람은 누구나 해당 채널에 글을 쓸 수 있다. + security-chat-webhook-url: ${SECURITY_ALERT_CHAT_WEBHOOK_URL} management: endpoints: @@ -71,4 +84,4 @@ project: logging: enabled: true env: ${SPRING_PROFILES_ACTIVE:local} - internal-secret: ${INTERNAL_LOGGING_SECRET:default-secret} + internal-secret: ${INTERNAL_LOGGING_SECRET} diff --git a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt index 73b22ac..0c2f650 100644 --- a/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/JacksonInstantIntegrationTest.kt @@ -32,6 +32,8 @@ import java.time.ZoneOffset "jwt.access-exp=15m", "jwt.refresh-exp=14d", "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", "spring.datasource.url=jdbc:h2:mem:jackson-instant-test;DB_CLOSE_DELAY=-1", "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", diff --git a/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt b/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt new file mode 100644 index 0000000..2381948 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/MemberRevocationStateQueryTest.kt @@ -0,0 +1,86 @@ +package com.wq.auth.integration + +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.entity.MemberEntity +import io.kotest.core.spec.style.StringSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Instant + +/** + * 폐기 판정 쿼리의 **프로젝션 바인딩**을 실제 DB로 검증한다. + * + * 서비스 단위 테스트는 리포지토리를 mock 하므로, 프로젝션 별칭이 잘못돼도 통과한다. + * 이 판정은 인증의 핵심이라 "쿼리가 실제로 값을 채워 오는가"를 별도로 확인한다. + */ +@SpringBootTest( + properties = [ + "jwt.secret=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=", + "jwt.access-exp=15m", + "jwt.refresh-exp=14d", + "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", + "spring.datasource.url=jdbc:h2:mem:revocation-state-test;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.mail.host=localhost", + "spring.mail.port=25", + "spring.mail.username=test", + "spring.mail.password=test", + "spring.mail.properties.mail.smtp.auth=false", + "spring.mail.properties.mail.smtp.starttls.enable=false", + ] +) +class MemberRevocationStateQueryTest : StringSpec() { + + override val extensions = listOf(SpringExtension()) + + @Autowired + lateinit var memberRepository: MemberRepository + + init { + "회원이 없으면 null 을 돌려준다 — 탈퇴(hard delete)를 이 값으로 판정한다" { + memberRepository.findRevocationStateByOpaqueId("존재하지-않는-id") shouldBe null + } + + "폐기 이력이 없는 정상 회원은 isDeleted=false, tokensInvalidBefore=null" { + val member = memberRepository.save(MemberEntity.create(nickname = "정상회원")) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.isDeleted shouldBe false + state.tokensInvalidBefore shouldBe null + } + + "revokeTokens() 로 기록한 시각이 그대로 조회된다" { + val at = Instant.parse("2026-08-30T12:00:00Z") + val member = MemberEntity.create(nickname = "로그아웃회원").apply { revokeTokens(at) } + memberRepository.save(member) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.tokensInvalidBefore shouldBe at + } + + "soft delete 된 회원은 행이 남지만 isDeleted=true 로 조회된다" { + // 계정 병합(MemberConnector)이 흡수된 회원을 이렇게 처리한다. + // 행이 남으므로 null 로는 구분할 수 없고, 이 플래그로만 알 수 있다. + val member = MemberEntity.create(nickname = "병합된회원").apply { softDelete() } + memberRepository.save(member) + + val state = memberRepository.findRevocationStateByOpaqueId(member.opaqueId) + + state.shouldNotBeNull() + state.isDeleted shouldBe true + } + } +} diff --git a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt index 2533968..d1a4edc 100644 --- a/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt +++ b/src/test/kotlin/com/wq/auth/integration/security/SecurityAuthorizationIntegrationTest.kt @@ -26,6 +26,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = [ "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=", "spring.datasource.url=jdbc:h2:mem:security-test;DB_CLOSE_DELAY=-1", "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", @@ -59,8 +61,14 @@ class JwtSpringSecurityIntegrationTest : BehaviorSpec() { `when`("공개 API에 토큰 없이 접근하면") { then("200 OK 응답을 받아야 한다") { + // permitAll 경로. 과거에는 TestSecurityController 의 /api/public/test 를 썼으나 + // 그 컨트롤러가 인증 없이 임의 사용자 AT 를 발급해 삭제되면서 옮겼다. + // + // /actuator/health 는 쓰지 않는다 — 테스트 환경에서 health 인디케이터가 DOWN 이라 + // 503 이 나서 "보안 통과 여부"가 아니라 "인프라 상태"를 재는 테스트가 되어 버린다. + // /v3/api-docs 는 permitAll 이면서 외부 의존이 없어 결과가 안정적이다. val result = mockMvc.perform( - get("/api/public/test") + get("/v3/api-docs") .contentType(MediaType.APPLICATION_JSON) ).andReturn() @@ -75,8 +83,10 @@ class JwtSpringSecurityIntegrationTest : BehaviorSpec() { opaqueId = "550e8400-e29b-41d4-a716-446655440000" ) + // 인증 필요 경로. 삭제된 TestSecurityController 의 /api/test 대신 + // 토큰을 발급하지 않는 테스트 전용 프로브(_SecurityProbeController)를 쓴다. val result = mockMvc.perform( - get("/api/test") + get("/api/test-probe/authenticated") .header("Authorization", "Bearer $memberToken") .contentType(MediaType.APPLICATION_JSON) ).andReturn() diff --git a/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt b/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt new file mode 100644 index 0000000..f168773 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/integration/security/_SecurityProbeController.kt @@ -0,0 +1,19 @@ +package com.wq.auth.integration.security + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Spring Security 배선(permitAll vs authenticated)만 확인하기 위한 테스트 전용 컨트롤러. + * + * src/test 에 있으므로 프로덕션 배포 아티팩트에 포함되지 않는다. + * 삭제된 TestSecurityController 와 결정적으로 다른 점은 **토큰을 발급하지 않는다**는 것이다 — + * 그 컨트롤러는 인증 없이 임의 opaqueId 의 유효 서명 AT 를 내주고 있었다. + */ +@RestController +class _SecurityProbeController { + + /** SecurityConfig 의 anyRequest().authenticated() 에 걸린다. 유효 토큰이 있어야 200. */ + @GetMapping("/api/test-probe/authenticated") + fun authenticatedProbe(): Map = mapOf("ok" to "true") +} diff --git a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt index c8d9582..da9855a 100644 --- a/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/AuthServiceTest.kt @@ -8,6 +8,7 @@ import com.wq.auth.api.domain.auth.AuthProviderRepository import com.wq.auth.api.domain.auth.AuthService import com.wq.auth.api.domain.auth.MemberConnector import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.MemberRevocationState import com.wq.auth.api.domain.member.MemberStatsService import com.wq.auth.api.domain.auth.RefreshTokenRepository import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity @@ -16,10 +17,12 @@ import com.wq.auth.api.domain.auth.error.AuthExceptionCode import com.wq.auth.security.jwt.JwtProvider import com.wq.auth.security.jwt.error.JwtException import com.wq.auth.security.jwt.error.JwtExceptionCode +import com.wq.auth.shared.alert.SecurityAlertNotifier import com.wq.auth.shared.utils.NicknameGenerator import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import org.mockito.kotlin.* import org.springframework.test.context.ActiveProfiles import java.time.Instant @@ -37,6 +40,7 @@ class AuthServiceTest : DescribeSpec({ lateinit var nicknameGenerator: NicknameGenerator lateinit var memberConnector: MemberConnector lateinit var memberStatsService: MemberStatsService + lateinit var securityAlertNotifier: SecurityAlertNotifier beforeEach { authProviderRepository = mock() @@ -47,6 +51,7 @@ class AuthServiceTest : DescribeSpec({ nicknameGenerator = mock() memberConnector = mock() memberStatsService = mock() + securityAlertNotifier = mock() authService = AuthService( authEmailService = authEmailService, @@ -57,6 +62,7 @@ class AuthServiceTest : DescribeSpec({ nicknameGenerator = nicknameGenerator, memberConnector = memberConnector, memberStatsService = memberStatsService, + securityAlertNotifier = securityAlertNotifier, ) } @@ -748,4 +754,170 @@ class AuthServiceTest : DescribeSpec({ } } -}) \ No newline at end of file + describe("assertTokenNotRevoked - 토큰 폐기 판정") { + + val opaqueId = "550e8400-e29b-41d4-a716-446655440000" + val token = "any-access-token" + + /** 폐기 상태 프로젝션 stub */ + fun state(isDeleted: Boolean = false, invalidBefore: Instant? = null) = + MemberRevocationState(isDeleted = isDeleted, tokensInvalidBefore = invalidBefore) + + it("회원 행이 없으면(탈퇴) 폐기로 보고 예외를 던진다") { + // 탈퇴는 hard delete 라 tokens_invalid_before 를 읽을 수조차 없다. + // 행 부재 자체를 폐기 신호로 써야 탈퇴 직후의 옛 AT 를 막을 수 있다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)).thenReturn(null) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("soft delete 된 계정이면 폐기로 보고 예외를 던진다") { + // 계정 병합(MemberConnector)이 흡수된 회원을 soft delete 한다. + // 행이 남으므로 null 로는 구분되지 않는다. 그 신원으로는 더 이상 로그인할 수 + // 없으므로 이미 발급된 토큰도 무효여야 한다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(isDeleted = true)) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("soft delete 된 계정은 폐기 이력이 없어도 거부한다") { + // 병합 코드가 revokeTokens() 기록을 빠뜨려도 이 방어선이 막아야 한다. + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(isDeleted = true, invalidBefore = null)) + + shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + // 폐기 판정이 이미 끝났으므로 토큰을 파싱할 필요가 없다 + verify(jwtProvider, never()).getIssuedAt(any()) + } + + it("폐기 이력이 없는 정상 회원은 통과한다") { + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state()) + + authService.assertTokenNotRevoked(token, opaqueId) + + // 폐기 이력이 없으면 iat 를 읽을 필요조차 없다 (핫패스 비용 절약) + verify(jwtProvider, never()).getIssuedAt(any()) + } + + it("폐기 시각보다 이전에 발급된 토큰이면 예외를 던진다") { + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore.minusSeconds(1)) + + val ex = shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + ex.jwtCode shouldBe JwtExceptionCode.EXPIRED + } + + it("폐기 시각과 같은 초에 발급된 토큰도 거부한다") { + // iat 는 초 단위라, 로그아웃과 같은 초에 발급된 토큰이 살아남으면 안 된다. + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore) + + shouldThrow { + authService.assertTokenNotRevoked(token, opaqueId) + } + } + + it("폐기 시각 이후에 발급된 토큰이면 통과한다") { + val invalidBefore = Instant.parse("2026-08-30T12:00:00Z") + whenever(memberRepository.findRevocationStateByOpaqueId(opaqueId)) + .thenReturn(state(invalidBefore = invalidBefore)) + whenever(jwtProvider.getIssuedAt(token)).thenReturn(invalidBefore.plusSeconds(1)) + + authService.assertTokenNotRevoked(token, opaqueId) + } + } + + describe("refreshAccessToken - RT 재사용 탐지") { + + val opaqueId = "550e8400-e29b-41d4-a716-446655440000" + val jti = "reused-jti" + val refreshToken = "any-refresh-token" + + it("이미 회전되어 폐기된 jti가 다시 오면 패밀리 전체를 폐기하고 예외를 던진다") { + // 도난된 RT 를 공격자가 먼저 쓰면, 정상 사용자의 다음 갱신에서 이 분기를 탄다. + val member = MemberEntity.create(nickname = "피해자") + val usedToken: RefreshTokenEntity = mock() + // 유예 창(기본 30초)을 한참 지난 시점에 회전된 토큰 = 도난 정황 + whenever(usedToken.deletedAt).thenReturn(Instant.now().minusSeconds(600)) + + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(usedToken) + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(member)) + + shouldThrow { + authService.refreshAccessToken(refreshToken, deviceId = null) + } + + // 계정이 탈취된 상황이므로 정상 사용자도 재로그인시키는 것이 옳다. + verify(refreshTokenRepository, times(1)).softDeleteAllByOpaqueId(eq(opaqueId), any()) + member.tokensInvalidBefore shouldNotBe null + // 로그에만 남기면 아무도 모른다 — 운영 채널로 알려야 한다 + verify(securityAlertNotifier, times(1)).refreshTokenReuseDetected(eq(opaqueId), eq(jti), any()) + } + + it("존재한 적 없는 jti면 패밀리를 건드리지 않고 예외만 던진다") { + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(null) + + shouldThrow { + authService.refreshAccessToken(refreshToken, deviceId = null) + } + + // 단순 오류·만료 토큰까지 패밀리를 폐기하면 정상 사용자를 불필요하게 로그아웃시킨다. + verify(refreshTokenRepository, never()).softDeleteAllByOpaqueId(any(), any()) + } + it("유예 창 안에 회전된 jti 면 도난으로 보지 않고 정상 발급한다") { + // AT 잔여 5분 미만 구간에서 페이지가 API 를 여러 개 동시 호출하면 + // 같은 RT 로 갱신이 겹친다. 여기서 실패시키면 silentRefresh 가 쿠키를 지워 + // 정상 사용자가 로그아웃된다. 사용자에게 보이지 않아야 하므로 발급까지 이어간다. + val member = MemberEntity.create(nickname = "정상사용자") + val justRotated: RefreshTokenEntity = mock() + whenever(justRotated.deletedAt).thenReturn(Instant.now().minusSeconds(2)) + + whenever(jwtProvider.getJti(refreshToken)).thenReturn(jti) + whenever(jwtProvider.getOpaqueId(refreshToken)).thenReturn(opaqueId) + whenever(refreshTokenRepository.findActiveByOpaqueIdAndJti(opaqueId, jti)).thenReturn(null) + whenever(refreshTokenRepository.findByOpaqueIdAndJtiIncludingDeleted(opaqueId, jti)) + .thenReturn(justRotated) + whenever(jwtProvider.getRefreshTokenExpiredAt(refreshToken)) + .thenReturn(Instant.now().plusSeconds(3600)) + whenever(jwtProvider.createAccessToken(any(), any())).thenReturn("new-at") + whenever(jwtProvider.createRefreshToken(any(), any())).thenReturn("new-rt") + whenever(jwtProvider.getJti("new-rt")).thenReturn("new-jti") + whenever(memberRepository.findByOpaqueId(opaqueId)).thenReturn(Optional.of(member)) + whenever(refreshTokenRepository.save(any())).thenReturn(mock()) + + val result = authService.refreshAccessToken(refreshToken, deviceId = null) + + result.accessToken shouldBe "new-at" + result.refreshToken shouldBe "new-rt" + // 패밀리를 폐기하지 않는다 — 폐기하면 사용자가 재로그인해야 한다 + verify(refreshTokenRepository, never()).softDeleteAllByOpaqueId(any(), any()) + member.tokensInvalidBefore shouldBe null + // 동시 요청까지 경보로 울리면 신호가 오염되어 진짜 도난을 놓친다 + verify(securityAlertNotifier, never()).refreshTokenReuseDetected(any(), any(), anyOrNull()) + } + } +}) diff --git a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt index 7f9a4e0..50c3152 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtPropertiesBindingTest.kt @@ -22,7 +22,9 @@ import java.time.Duration "spring.datasource.password=", "spring.jpa.hibernate.ddl-auto=create-drop", "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", - "INTERNAL_API_SECRET=test-internal-secret" + "INTERNAL_API_SECRET=test-internal-secret", + "INTERNAL_LOGGING_SECRET=test-logging-secret", + "SECURITY_ALERT_CHAT_WEBHOOK_URL=" ] ) @ConfigurationPropertiesScan diff --git a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt index ab0fc59..1d04555 100644 --- a/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/JwtProviderTest.kt @@ -14,6 +14,7 @@ import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import java.time.Duration +import java.time.Instant import java.util.Base64 import javax.crypto.SecretKey @@ -134,8 +135,37 @@ class JwtProviderTest : StringSpec({ "토큰 유효성 검증이 정상 동작한다" { val validToken = provider.createAccessToken("test-user") - + // 예외 없이 통과해야 함 provider.validateOrThrow(validToken) } + + "getIssuedAt()은 발급 시각(iat)을 돌려준다" { + val before = Instant.now().minusSeconds(2) + val token = provider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") + val after = Instant.now().plusSeconds(2) + + val issuedAt = provider.getIssuedAt(token) + + (issuedAt.isAfter(before) && issuedAt.isBefore(after)).shouldBeTrue() + } + + "getIssuedAt()의 iat 는 초 단위다 - 폐기 판정이 이 정밀도에 의존한다" { + val token = provider.createAccessToken("550e8400-e29b-41d4-a716-446655440000") + + val issuedAt = provider.getIssuedAt(token) + + // JWT 표준상 iat 는 초 단위라 밀리초가 잘린다. 그래서 폐기 판정은 + // 같은 초에 발급된 토큰까지 거부하도록 "iat > invalidBefore" 가 아닌 + // "!(iat > invalidBefore)" 형태로 비교해야 한다. + issuedAt.nano shouldBe 0 + } + + "getIssuedAt()은 서명이 위조된 토큰이면 예외를 던진다" { + val forged = provider.createAccessToken("test-user").dropLast(4) + "AAAA" + + shouldThrow { + provider.getIssuedAt(forged) + } + } }) \ No newline at end of file diff --git a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt index 33fea5a..e92bb67 100644 --- a/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt +++ b/src/test/kotlin/com/wq/auth/unit/MemberEntityTest.kt @@ -5,6 +5,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import java.time.Instant import java.util.* class MemberEntityTest : StringSpec({ @@ -95,5 +96,37 @@ class MemberEntityTest : StringSpec({ // ) } */ + + "새로 만든 회원은 폐기 이력이 없다" { + val member = MemberEntity.create(nickname = "테스터") + + member.tokensInvalidBefore shouldBe null + } + + "revokeTokens()는 전달한 시각을 폐기 기준으로 기록한다" { + // Given + val member = MemberEntity.create(nickname = "테스터") + val at = Instant.parse("2026-08-30T12:00:00Z") + + // When + member.revokeTokens(at) + + // Then + member.tokensInvalidBefore shouldBe at + } + + "revokeTokens()를 인자 없이 부르면 현재 시각으로 기록한다" { + // Given + val member = MemberEntity.create(nickname = "테스터") + val before = Instant.now().minusSeconds(1) + + // When + member.revokeTokens() + + // Then + val recorded = member.tokensInvalidBefore + recorded shouldNotBe null + recorded!!.isAfter(before) shouldBe true + } }) diff --git a/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt b/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt new file mode 100644 index 0000000..c484439 --- /dev/null +++ b/src/test/kotlin/com/wq/auth/unit/SecurityAlertNotifierTest.kt @@ -0,0 +1,118 @@ +package com.wq.auth.unit + +import com.sun.net.httpserver.HttpServer +import com.wq.auth.shared.alert.SecurityAlertNotifier +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.springframework.web.client.RestClient +import java.net.InetSocketAddress +import java.time.Instant +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * 실제 HTTP 요청이 나가는지 로컬 스텁 서버로 확인한다. + * + * 목킹만 하면 "호출했다"까지만 알 수 있고, **실제로 어떤 본문이 어떤 헤더로 나가는지**는 + * 모른다. Google Chat 은 `{"text": "..."}` 형태를 요구하므로 그 계약을 여기서 고정한다. + */ +class SecurityAlertNotifierTest : StringSpec({ + + /** 요청을 받아 본문을 기록하는 최소 스텁 서버 */ + class Stub(status: Int = 200) { + val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + @Volatile var body: String? = null + @Volatile var contentType: String? = null + @Volatile var method: String? = null + val received = CountDownLatch(1) + + init { + server.createContext("/hook") { ex -> + method = ex.requestMethod + contentType = ex.requestHeaders.getFirst("Content-Type") + body = ex.requestBody.readBytes().decodeToString() + ex.sendResponseHeaders(status, -1) + ex.close() + received.countDown() + } + server.start() + } + + val url: String get() = "http://127.0.0.1:${server.address.port}/hook" + fun awaitRequest() = received.await(5, TimeUnit.SECONDS) + fun stop() = server.stop(0) + } + + fun notifier(url: String) = SecurityAlertNotifier( + restClient = RestClient.create(), + webhookUrl = url, + environment = "test", + ) + + "웹훅으로 Google Chat 형식의 JSON 을 POST 한다" { + val stub = Stub() + try { + notifier(stub.url).refreshTokenReuseDetected( + opaqueId = "550e8400-e29b-41d4-a716-446655440000", + jti = "stolen-jti", + rotatedAt = Instant.parse("2026-08-30T12:00:00Z"), + ) + + stub.awaitRequest() shouldBe true + stub.method shouldBe "POST" + stub.contentType shouldContain "application/json" + + val body = stub.body!! + // Google Chat 은 text 필드를 요구한다 + body shouldContain "\"text\"" + body shouldContain "RT 재사용 감지" + // alpha 와 prod 가 같은 채널을 공유하므로 환경이 제목에 드러나야 한다 + body shouldContain "[TEST]" + body shouldContain "550e8400-e29b-41d4-a716-446655440000" + body shouldContain "stolen-jti" + body shouldContain "2026-08-30T12:00:00Z" + // 조사 지침이 메시지에 들어 있어야 알림을 받은 사람이 판단할 수 있다 + body shouldContain "동시다발" + } finally { + stub.stop() + } + } + + "메시지에 웹훅 URL 이나 토큰 값이 실리지 않는다" { + val stub = Stub() + try { + notifier(stub.url).refreshTokenReuseDetected("user-1", "jti-1", Instant.now()) + stub.awaitRequest() shouldBe true + + val body = stub.body!! + // 알림 채널로 자격증명이 새면 알림 자체가 사고가 된다 + body shouldNotContain "127.0.0.1" + body shouldNotContain "/hook" + } finally { + stub.stop() + } + } + + "채널이 5xx 를 돌려줘도 예외를 밖으로 내보내지 않는다" { + // 알림 실패가 인증 실패가 되면 안 된다. + val stub = Stub(status = 500) + try { + notifier(stub.url).refreshTokenReuseDetected("user-1", "jti-1", null) + stub.awaitRequest() shouldBe true + } finally { + stub.stop() + } + } + + "웹훅 URL 이 없으면 전송을 건너뛰고 예외도 던지지 않는다" { + // 설정이 없어도 기동·동작해야 한다. 알림만 빠진다. + notifier("").refreshTokenReuseDetected("user-1", "jti-1", null) + } + + "연결할 수 없는 주소여도 예외를 밖으로 내보내지 않는다" { + // 포트 1은 열려 있지 않다 + notifier("http://127.0.0.1:1/hook").refreshTokenReuseDetected("user-1", "jti-1", null) + } +})