Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a1e1788
Update deploy.yml
imeasy99 Apr 6, 2026
6ad2aa9
feat: 환경 및 로깅 설정 개선
imeasy99 Apr 6, 2026
0f23c3a
feat: API 명세 문서 추가
imeasy99 Apr 8, 2026
055300c
feat: 소셜 로그인 V2 API 추가 및 기존 로직 개선
imeasy99 May 11, 2026
0e19ed3
feat: AT/RT 처리 방식 개선 및 사일런트 리프레시 로직 리팩토링
imeasy99 May 11, 2026
6515393
feat: Google 앱 로그인 서비스 추가
imeasy99 May 11, 2026
6cfd4f3
feat: 안드로이드 앱 전용 Google ID Token 로그인 API 추가
imeasy99 May 12, 2026
de4b6b9
feat: Google 앱 로그인 API에서 X-Client-Id 헤더 제거
imeasy99 May 12, 2026
82a0928
feat: 안드로이드 앱 전용 Google ID Token 로그인 경로 추가
imeasy99 May 12, 2026
482c9ca
feat: 내부 API 경로 및 설정 추가
imeasy99 May 13, 2026
3494612
feat: 내부 회원 정보 조회 API 추가
imeasy99 May 13, 2026
d9ab72f
feat: 로그 추가 및 예외 처리 로직 개선
imeasy99 May 14, 2026
7b40d9c
fix: X-Client-Type 기반 웹/앱 클라이언트 구분 방식 수정
imeasy99 May 21, 2026
40831e9
feat: 네이버 소셜로그인 전화번호 저장 및 user-info 응답 추가 (#68)
imeasy99 Jul 9, 2026
fdc51e8
Merge remote-tracking branch 'origin/main' into dev
imeasy99 Jul 22, 2026
6364865
fix: alpha/prod 쿠키 SameSite를 Strict에서 Lax로 변경 (#70)
imeasy99 Jul 22, 2026
2255f05
feat: 회원탈퇴(계정 즉시 삭제) API + SB4·kotest6 테스트 인프라 마이그레이션 (#71)
imeasy99 Aug 3, 2026
dacb9fe
chore: actuator health 엔드포인트·로깅 설정 추가 및 문서 정리 (#72)
imeasy99 Aug 3, 2026
e99895d
feat(auth): 토큰 폐기 즉시 반영 · RT 재사용 탐지 · 설정 기본값 정리 (#74)
imeasy99 Aug 30, 2026
e6a0c7a
Merge remote-tracking branch 'origin/main' into tmp-release-merge
imeasy99 Aug 30, 2026
8c4c9e1
fix(config): main 병합 시 중복 생성된 management 블록 제거
imeasy99 Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
93 changes: 91 additions & 2 deletions src/main/kotlin/com/wq/auth/api/domain/auth/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {}

Expand Down Expand Up @@ -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()) {
Expand All @@ -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) {
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class MemberConnector(
}

// 연동된 회원을 soft delete 처리
// 이 신원으로는 더 이상 로그인할 수 없으므로 이미 발급된 토큰도 함께 폐기한다.
// (기록하지 않으면 흡수된 opaqueId 의 AT 가 만료까지 계속 통과한다)
linkedMember.revokeTokens()
linkedMember.softDelete()
memberRepository.save(linkedMember)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,26 @@ interface RefreshTokenRepository : JpaRepository<RefreshTokenEntity, Long> {
@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

}
25 changes: 25 additions & 0 deletions src/main/kotlin/com/wq/auth/api/domain/member/MemberRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,4 +28,28 @@ interface MemberRepository : JpaRepository<MemberEntity, Long> {
): Optional<MemberEntity>

fun findByOpaqueIdAndIsDeletedFalse(opaqueId: String): Optional<MemberEntity>

/**
* 토큰 폐기 판정에 필요한 상태만 조회합니다.
*
* 세 가지 상황을 구분해야 합니다.
* ① **결과가 `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?
}
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ class MemberService(
)
)

// 회원 행은 바로 아래에서 삭제되지만, 삭제 트랜잭션 커밋 전후의 짧은 창과
// 게이트웨이 introspect 캐시 때문에 폐기 시각도 함께 남긴다.
// 삭제된 이후의 판정은 introspect 의 "회원 부재 = 폐기" 규칙이 담당한다.
member.revokeTokens()

refreshTokenRepository.deleteByMember(member)
authProviderRepository.deleteByMember(member)
memberRepository.delete(member)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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')"
}
Expand Down
Loading