From 0fb83b4ef362b4fe8258f380b16401f319d83b45 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:16:18 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EA=B3=BC=20=EC=84=B8=EC=85=98=20=EC=83=9D=EB=AA=85?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ablish-auth-token-and-session-lifecycle.md | 257 ++++++++++++++++++ backend/docs/adr/README.md | 1 + 2 files changed, 258 insertions(+) create mode 100644 backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md diff --git a/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md b/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md new file mode 100644 index 0000000..9f5e28b --- /dev/null +++ b/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md @@ -0,0 +1,257 @@ +# 0008. 인증 토큰 발급과 세션 생명주기를 정의한다 + +- 날짜: 2026-08-19 +- 관련 이슈: [#65](https://github.com/woowacourse-teams/2026-ChongChong/issues/65) + +## 배경 + +[Access Token 인증 경계 ADR](0007-establish-access-token-authentication-boundary.md)에서는 클라이언트가 전달한 총총 +Access Token의 서명과 표준 Claim을 검증하고, `sub`를 내부 사용자 ID로 변환하는 방식을 결정했다. 그러나 현재 운영 +코드에는 총총 Access Token과 Refresh Token을 발급하거나 Refresh Token의 유효 상태를 저장하는 기능이 없다. + +소셜 로그인과 자동 회원가입, Token 재발급 및 로그아웃은 모두 총총 자체 Token을 발급하고 폐기하는 공통 기반을 +필요로 한다. 이 기반을 소셜 제공자 연동과 함께 구현하면 제공자 통신, 사용자 생성, Token 보안 정책과 동시성 제어가 한 +변경에 섞여 각각의 책임을 독립적으로 검증하기 어렵다. + +따라서 이 이슈에서는 이미 존재하는 총총 사용자 ID를 기준으로 Access/Refresh Token을 발급하고, 사용자당 하나의 +활성 Refresh Session을 저장하는 내부 기반을 먼저 구성한다. 실제 로그인, 재발급 및 로그아웃 API는 이 기반을 사용하는 +후속 작업으로 분리한다. + +## 결정 + +### Token의 역할 + +- Access Token은 총총 API 요청에서 현재 사용자를 증명하는 짧은 수명의 자격 증명이다. +- Refresh Token은 Access Token을 다시 발급받기 위한 긴 수명의 자격 증명이다. +- Access Token은 서버에 발급 상태를 저장하지 않는 JWT로 사용한다. +- Refresh Token은 서버가 현재 유효 상태를 `auth_sessions`에서 관리하는 불투명 Token으로 사용한다. +- 총총의 HTTP Session은 인증 상태 저장에 사용하지 않는다. + +### Access Token 발급 + +- Access Token은 Spring Security의 `JwtEncoder`를 사용해 발급한다. +- 별도의 Auth JWT 라이브러리를 추가하지 않는다. +- 서명은 1차 인증 경계와 동일한 HMAC SHA-256(`HS256`)을 사용한다. +- 발급기와 검증기는 동일한 Base64 서명 키와 `issuer`, `audience` 설정을 사용한다. +- 서명 키를 Base64로 복호화하고 최소 256비트인지 확인하는 책임은 발급기와 검증기가 공유한다. +- Access Token의 기본 만료 시간은 30분이며 환경 설정으로 변경할 수 있다. +- 발급·만료 시각은 주입한 `Clock`의 `Instant`를 기준으로 계산한다. +- `Clock`의 기본 운영 값은 UTC 기준 시스템 시계다. + +Access Token에는 1차 ADR에서 결정한 다음 표준 Claim을 포함한다. + +| Claim | 값 | +| --- | --- | +| `iss` | 설정된 총총 Token 발급자 | +| `aud` | 설정된 총총 API audience | +| `sub` | 양의 정수인 총총 내부 `users.id`의 문자열 표현 | +| `iat` | 발급 시각 | +| `exp` | 발급 시각에 Access Token 만료 시간을 더한 시각 | +| `jti` | Token마다 새로 생성한 고유 식별자 | + +Access Token에는 다음 값을 넣지 않는다. + +- `MEMBER`, `LEADER`와 같은 스터디별 역할 +- 닉네임, 프로필 이미지, 이메일과 같은 개인정보 +- 소셜 제공자의 사용자 ID와 Access/Refresh Token +- 총총 Refresh Token 또는 그 해시 + +### Refresh Token 생성 + +- Refresh Token은 Claim을 담지 않는 불투명한 무작위 문자열로 발급한다. +- Java `SecureRandom`으로 32바이트(256비트)의 무작위 값을 생성한다. +- 생성한 바이트는 URL-safe Base64 방식으로 padding 없이 인코딩한다. +- Refresh Token의 기본 만료 시간은 30일이며 환경 설정으로 변경할 수 있다. +- Access Token과 Refresh Token의 만료 시간은 서로 다른 설정으로 관리한다. +- 원문 Refresh Token은 발급 결과를 만드는 애플리케이션 경계까지만 전달하고 로그, 예외 메시지와 영속 Entity에 넣지 + 않는다. + +### Refresh Token 해시 + +- 데이터베이스에는 Refresh Token 원문 대신 SHA-256 해시를 저장한다. +- 해시 바이트는 소문자 16진수 64자리 문자열로 표현한다. +- 같은 원문은 같은 해시를 만들고, 원문을 전달받은 재발급 요청은 같은 방식으로 해시한 뒤 저장 값과 비교한다. +- Refresh Token은 256비트의 무작위 엔트로피를 가지므로 비밀번호처럼 사람이 정한 낮은 엔트로피 값을 전제로 하는 + 느린 비밀번호 해시를 사용하지 않는다. +- 원문과 해시가 타입 또는 변수 이름에서 명확히 구분되도록 한다. + +### 인증 Session 저장 + +`auth_sessions`는 다음 정보를 저장한다. + +| 컬럼 | 규칙 | +| --- | --- | +| `id` | Session 식별자 | +| `user_id` | 총총 내부 사용자, `NOT NULL`, `UNIQUE` | +| `refresh_token_hash` | SHA-256 해시 문자열, `NOT NULL`, `UNIQUE`, 길이 64 | +| `expires_at` | Refresh Token 만료 시각, `NOT NULL` | +| `created_at` | 기존 `BaseEntity` 규칙 사용 | +| `updated_at` | 기존 `BaseEntity` 규칙 사용 | + +- `AuthSession`은 `auth` 도메인이 소유한다. +- `AuthSession`은 `User`를 참조하지만 `User`가 `AuthSession` 컬렉션을 갖는 양방향 관계는 만들지 않는다. +- `user_id` 유일 제약으로 MVP에서 사용자당 활성 Refresh Session을 하나만 허용한다. +- 현재 ERD는 향후 사용자와 Session의 1:N 확장을 허용하는 형태지만, 이번 구현에서는 이슈 #65의 단일 활성 Session + 성공 기준을 우선해 `user_id` 유일 제약을 적용한다. +- `refresh_token_hash` 유일 제약으로 같은 Refresh Token 해시가 여러 Session에 저장되지 않게 한다. +- 현재 프로젝트의 스키마 관리 방식인 JPA `ddl-auto`를 유지한다. 이 이슈에서 Flyway 등 별도 마이그레이션 도구를 + 함께 도입하지 않는다. + +### 최초 Token 발급과 Session 교체 + +Token 발급 서비스는 다음 순서로 동작한다. + +1. 전달받은 사용자 ID가 양수인지 확인한다. +2. 해당 `User`를 비관적 쓰기 잠금으로 조회해 같은 사용자에 대한 동시 발급 요청을 직렬화한다. +3. 새 Access Token과 원문 Refresh Token을 생성한다. +4. Refresh Token 원문을 SHA-256으로 해시한다. +5. 기존 `AuthSession`이 없으면 새로 저장하고, 있으면 해시와 만료 시각을 새 값으로 교체한다. +6. Transaction이 정상적으로 끝난 뒤 호출자에게 Token 쌍과 각각의 만료 시각을 반환한다. + +두 번째 Token 쌍을 발급하면 이전 Refresh Token의 해시는 더 이상 현재 Session에 남지 않는다. 따라서 후속 재발급 +기능에서 이전 Refresh Token을 현재 Token으로 인정하지 않는다. + +Token 생성 중 오류가 발생하거나 Session 저장에 실패하면 Token 결과를 호출자에게 반환하지 않는다. Session 교체는 +하나의 Transaction에서 처리한다. + +### 동시 발급 + +- 동일 사용자에 대한 Token 발급은 `User` 행의 비관적 쓰기 잠금으로 직렬화한다. +- `user_id` 데이터베이스 유일 제약을 최종 안전장치로 함께 사용한다. +- 서로 다른 사용자의 Token 발급은 서로의 Session을 교체하거나 잠그지 않는다. +- 동시성 보장은 Mock Repository 단위 테스트가 아니라 실제 데이터베이스와 Transaction을 사용하는 통합 테스트로 + 확인한다. + +### Token 발급 서비스의 경계 + +- 이번 이슈의 Token 발급 서비스는 이미 존재하는 총총 내부 사용자 ID를 입력으로 받는다. +- 외부 클라이언트가 임의의 사용자 ID로 Token을 발급받을 수 있는 HTTP API를 제공하지 않는다. +- 운영 코드에 `/dev/token`, 고정 사용자 로그인 또는 `X-User-Id` 인증 우회 기능을 만들지 않는다. +- 후속 소셜 로그인 서비스는 소셜 제공자 검증과 사용자 조회·생성을 완료한 뒤 이 Token 발급 서비스를 호출한다. +- Controller와 도메인 서비스가 JWT를 직접 생성하거나 파싱하지 않는다. + +### 후속 재발급과 로그아웃에서 지킬 원칙 + +이번 이슈에서 재발급과 로그아웃 API를 구현하지는 않지만 후속 작업은 다음 원칙을 따른다. + +- 정상 재발급 때마다 Access Token과 Refresh Token을 모두 새로 발급한다. +- 새 Refresh Token을 저장하면 직전 Refresh Token은 더 이상 사용할 수 없다. +- 현재 Session 해시만 저장하는 이번 구조는 교체된 이전 Token을 거부할 수 있지만, 어떤 Token 계열에서 과거 Token이 + 재사용됐는지를 식별하지는 못한다. +- Token family 또는 사용 이력을 이용한 적극적인 재사용 탐지는 후속 정책으로 결정한다. +- 로그아웃은 현재 사용자의 `AuthSession`을 제거해 이후 재발급을 차단한다. +- Stateless Access Token은 로그아웃 시 서버에서 즉시 폐기하지 않으며 최대 30분의 남은 만료 시간 동안 유효할 수 있다. + +### 웹과 앱의 Refresh Token 전달 + +이번 이슈의 내부 발급 결과는 원문 Refresh Token을 호출자에게 반환할 수 있지만, HTTP 응답에서 클라이언트로 전달하는 +방법은 결정하지 않는다. + +- 웹에서 `Secure`, `HttpOnly` Cookie를 사용할지 +- 앱에서 응답 본문과 운영체제 보안 저장소를 사용할지 +- Cookie의 `SameSite`, `Path`, `Domain`과 만료 속성 +- Cookie 사용 시 CSRF 방어 방식 + +위 항목은 실제 로그인·재발급 API를 구현하기 전에 별도 후속 결정으로 확정한다. Cookie를 인증 정보 전달에 사용하면서 +현재의 `csrf.disable()`을 근거 없이 유지하지 않는다. + +## 선택 이유 + +짧은 수명의 JWT Access Token은 각 API 요청에서 데이터베이스 Session 조회 없이 1차 인증 경계로 검증할 수 있다. +Refresh Token만 서버 Session에서 관리하면 재발급과 로그아웃을 제어하면서 모든 Access Token의 발급 상태를 저장하는 +비용을 피할 수 있다. + +발급과 검증에 Spring Security의 `JwtEncoder`, `JwtDecoder`와 동일한 서명 설정을 사용하면 두 구현의 알고리즘, 키와 +Claim 규칙이 달라지는 위험을 줄일 수 있다. `Clock`을 주입하면 실제 시간을 기다리지 않고 발급 및 만료 경계를 안정적으로 +테스트할 수 있다. + +Refresh Token을 256비트 무작위 값으로 만들고 SHA-256 해시만 저장하면 데이터베이스가 노출되더라도 저장된 값 자체를 +Refresh Token으로 사용할 수 없다. 사용자당 하나의 Session과 사용자 행 잠금은 MVP의 단일 로그인 정책을 +데이터베이스 제약과 Transaction 수준에서 함께 보장한다. + +## 검토한 대안 + +### Access Token을 직접 조합하거나 별도 JWT 라이브러리로 발급 + +Token 생성 형식을 자유롭게 제어할 수 있지만 1차 검증에 사용하는 Spring Security 설정과 알고리즘·Claim 처리가 +분리된다. 같은 Auth Access Token에 두 JWT 구현을 사용할 필요가 없어 선택하지 않았다. 기존 JJWT 사용 코드는 스터디 +초대 Token이라는 별도 책임으로 유지한다. + +### Refresh Token도 JWT로 발급 + +만료 시각과 사용자 정보를 Token 자체에 담을 수 있지만 서버가 현재 유효 Session과 Rotation 상태를 관리하려면 결국 +저장소가 필요하다. Refresh Token Claim을 클라이언트가 해석할 이유가 없고 노출할 정보만 늘어나므로 불투명 Token을 +선택했다. + +### Refresh Token 원문 저장 + +원문 비교가 단순하지만 데이터베이스가 노출되면 공격자가 저장 값을 그대로 사용해 Token을 재발급할 수 있다. 해시 +계산 비용보다 유출 위험이 크므로 선택하지 않았다. + +### 사용자당 여러 활성 Session 허용 + +기기별 로그인 유지와 선택적 로그아웃에 유리하지만 Session 식별, 기기 관리, 전체 로그아웃과 재사용 탐지 정책이 추가로 +필요하다. 현재 MVP 방향은 사용자당 활성 Session 하나이므로 선택하지 않았다. 다중 기기 요구가 확정되면 `user_id` +유일 제약과 Session 모델을 새 ADR 및 DB 변경으로 재검토한다. + +### 애플리케이션 조회만으로 단일 Session 보장 + +Session 저장 전에 기존 행을 조회하고 삭제하는 방식은 단순하지만 동일 사용자의 동시 요청이 모두 기존 Session이 없다고 +판단할 수 있다. 사용자 행 잠금과 데이터베이스 유일 제약 없이 애플리케이션의 사전 조회만 사용하는 방식은 선택하지 +않았다. + +### 서버 HTTP Session 인증 + +서버가 로그인 상태를 즉시 폐기하기 쉽지만 웹과 이후 모바일 앱이 동일한 Bearer Token API를 사용하고 총총 자체 +Access/Refresh Token을 발급한다는 1차 결정과 맞지 않아 선택하지 않았다. + +## 영향 + +### 긍정적 영향 + +- 실제 소셜 제공자 연동 전에 총총 Token 발급 규칙을 독립적으로 구현하고 검증할 수 있다. +- 발급한 Access Token을 기존 Resource Server 경계로 바로 검증할 수 있다. +- Refresh Token 원문이 데이터베이스에 남지 않는다. +- 사용자당 하나의 활성 Refresh Session을 데이터베이스 제약으로 보장한다. +- 동시 발급에서도 같은 사용자의 Session이 중복 생성되는 것을 막을 수 있다. +- 시간 의존 로직을 실제 대기 없이 테스트할 수 있다. +- 로그인, 재발급과 로그아웃이 공통 Token 발급 서비스를 재사용할 수 있다. + +### 부정적 영향과 위험 + +- 사용자당 하나의 Session만 허용하므로 다른 기기에서 로그인하면 기존 기기의 Refresh Token이 무효화된다. +- 동일 사용자 Token 발급 중에는 사용자 행 잠금 비용이 발생한다. +- 로그아웃이나 Session 교체 직후에도 기존 Access Token은 최대 30분 동안 유효할 수 있다. +- 단일 현재 해시만 저장하므로 교체된 과거 Refresh Token의 계열과 재사용 사건을 식별할 수 없다. +- HMAC 서명 키가 노출되면 공격자가 유효한 Access Token을 만들 수 있으므로 운영 키 보관과 교체 전략이 필요하다. +- JPA `ddl-auto`에 의존하므로 운영 데이터가 생긴 뒤 제약이나 컬럼을 변경할 때 별도의 안전한 DB 변경 전략이 필요하다. + +## 미확정 사항 + +- 웹과 앱에서 Refresh Token을 전달하는 최종 방식 +- 웹 Refresh Cookie의 이름, `Secure`, `HttpOnly`, `SameSite`, `Path`, `Domain`과 만료 속성 +- Cookie 기반 Refresh 요청의 CSRF 방어 방식 +- 재발급 실패의 외부 에러 코드와 상세 메시지 +- Token family 또는 이력을 이용한 Refresh Token 재사용 탐지와 대응 범위 +- 다중 기기 Session 지원 시 Session 식별과 선택적 로그아웃 정책 +- HMAC 서명 키 교체 시 기존 Access Token과의 전환 방식 +- 탈퇴·정지 사용자에게 남아 있는 Access Token의 처리 방식 + +## 후속 작업 + +- Access/Refresh Token 만료 시간을 설정 값으로 추가한다. +- 발급기와 검증기가 공유할 HMAC `SecretKey` 구성을 분리한다. +- Spring Security `JwtEncoder`와 Access Token 발급기를 구현한다. +- Refresh Token 생성기와 SHA-256 해시 컴포넌트를 구현한다. +- `AuthSession` Entity와 Repository 및 데이터베이스 제약을 구현한다. +- 사용자 행 잠금을 사용하는 Token 쌍 발급 서비스를 구현한다. +- Claim, 만료, 해시, Session 교체와 동시 발급을 테스트한다. +- 발급한 Access Token이 기존 `JwtDecoder`를 통과하는지 검증한다. +- 후속 이슈에서 소셜 로그인과 자동 회원가입을 구현한다. +- 후속 이슈에서 Rotation, 재발급, 로그아웃과 Cookie/CSRF 정책을 구현한다. + +## 참고 자료 + +- [Spring Security OAuth2](https://docs.spring.io/spring-security/reference/7.0/servlet/oauth2/index.html) +- [Spring Security CSRF](https://docs.spring.io/spring-security/reference/7.1-SNAPSHOT/features/exploits/csrf.html) +- [RFC 9700: OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700.html) diff --git a/backend/docs/adr/README.md b/backend/docs/adr/README.md index 7c736dd..6f2db26 100644 --- a/backend/docs/adr/README.md +++ b/backend/docs/adr/README.md @@ -24,3 +24,4 @@ ADR은 코드가 무엇을 하는지만 설명하지 않고, 당시 어떤 대 | [0005](0005-transfer-data-across-layer-boundaries.md) | 레이어 경계에서 전용 데이터 객체를 사용한다 | | [0006](0006-design-common-exception-types.md) | 공통 예외 처리 타입의 책임을 분리한다 | | [0007](0007-establish-access-token-authentication-boundary.md) | Access Token 인증 경계를 구성한다 | +| [0008](0008-establish-auth-token-and-session-lifecycle.md) | 인증 토큰 발급과 세션 생명주기를 정의한다 | From 05e5cd3d4234aaaa1c0f81fe3bc50b65b466a414 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:56:44 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=B4=9D=EC=B4=9D=20Access=20Tok?= =?UTF-8?q?en=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongchong/auth/config/JwtConfig.java | 76 +++++++++++++ .../chongchong/auth/config/JwtProperties.java | 13 ++- .../auth/config/SecurityConfig.java | 49 --------- .../auth/token/AccessTokenIssuer.java | 50 +++++++++ .../auth/token/IssuedAccessToken.java | 14 +++ backend/src/main/resources/application.yaml | 1 + ...rityConfigTest.java => JwtConfigTest.java} | 29 +++-- .../auth/token/AccessTokenIssuerTest.java | 103 ++++++++++++++++++ .../src/test/resources/application-test.yaml | 1 + 9 files changed, 277 insertions(+), 59 deletions(-) create mode 100644 backend/src/main/java/withoutc/chongchong/auth/config/JwtConfig.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/AccessTokenIssuer.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java rename backend/src/test/java/withoutc/chongchong/auth/config/{SecurityConfigTest.java => JwtConfigTest.java} (81%) create mode 100644 backend/src/test/java/withoutc/chongchong/auth/token/AccessTokenIssuerTest.java diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/JwtConfig.java b/backend/src/main/java/withoutc/chongchong/auth/config/JwtConfig.java new file mode 100644 index 0000000..6777731 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/config/JwtConfig.java @@ -0,0 +1,76 @@ +package withoutc.chongchong.auth.config; + +import java.time.Clock; +import java.util.Base64; +import java.util.List; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class JwtConfig { + + private static final int MINIMUM_HMAC_KEY_BYTES = 32; + + @Bean + public SecretKey authJwtSecretKey(JwtProperties properties) { + byte[] keyBytes; + + try { + keyBytes = Base64.getDecoder().decode(properties.secretBase64()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("JWT 서명 키는 Base64 형식이어야 합니다.", e); + } + + if (keyBytes.length < MINIMUM_HMAC_KEY_BYTES) { + throw new IllegalStateException("JWT 서명 키는 256비트 이상이어야 합니다."); + } + + return new SecretKeySpec(keyBytes, "HmacSHA256"); + } + + @Bean + public JwtEncoder jwtEncoder(SecretKey authJwtSecretKey) { + return NimbusJwtEncoder.withSecretKey(authJwtSecretKey) + .algorithm(MacAlgorithm.HS256) + .build(); + } + + @Bean + public JwtDecoder jwtDecoder( + SecretKey authJwtSecretKey, + JwtProperties properties + ) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(authJwtSecretKey) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + + OAuth2TokenValidator issuerValidator = JwtValidators.createDefaultWithIssuer(properties.issuer()); + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + audiences -> audiences != null && audiences.contains(properties.audience()) + ); + + jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator)); + return jwtDecoder; + } + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java b/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java index 2dde685..121b2c8 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java +++ b/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java @@ -1,6 +1,8 @@ package withoutc.chongchong.auth.config; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -9,6 +11,15 @@ public record JwtProperties( @NotBlank String issuer, @NotBlank String audience, - @NotBlank String secretBase64 + @NotBlank String secretBase64, + @NotNull Duration accessTokenValidity ) { + + public JwtProperties { + if (accessTokenValidity == null + || accessTokenValidity.isZero() + || accessTokenValidity.isNegative()) { + throw new IllegalArgumentException("Access Token 유효 시간은 0보다 커야 합니다."); + } + } } diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/SecurityConfig.java b/backend/src/main/java/withoutc/chongchong/auth/config/SecurityConfig.java index f254428..8091053 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/config/SecurityConfig.java +++ b/backend/src/main/java/withoutc/chongchong/auth/config/SecurityConfig.java @@ -1,24 +1,10 @@ package withoutc.chongchong.auth.config; -import java.util.Base64; -import java.util.List; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.jwt.JwtClaimValidator; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.web.SecurityFilterChain; import tools.jackson.databind.ObjectMapper; import withoutc.chongchong.auth.security.AuthenticatedUserJwtAuthenticationConverter; @@ -26,11 +12,8 @@ import withoutc.chongchong.auth.security.RestAuthenticationEntryPoint; @Configuration -@EnableConfigurationProperties(JwtProperties.class) public class SecurityConfig { - private static final int MINIMUM_HMAC_KEY_BYTES = 32; - @Bean SecurityFilterChain securityFilterChain( HttpSecurity http, @@ -72,36 +55,4 @@ RestAuthenticationEntryPoint authenticationEntryPoint(ObjectMapper objectMapper) RestAccessDeniedHandler accessDeniedHandler(ObjectMapper objectMapper) { return new RestAccessDeniedHandler(objectMapper); } - - @Bean - JwtDecoder jwtDecoder(JwtProperties properties) { - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(secretKey(properties.secretBase64())) - .macAlgorithm(MacAlgorithm.HS256) - .build(); - - OAuth2TokenValidator issuerValidator = JwtValidators.createDefaultWithIssuer(properties.issuer()); - OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( - JwtClaimNames.AUD, - audiences -> audiences != null && audiences.contains(properties.audience()) - ); - - jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator)); - return jwtDecoder; - } - - private SecretKey secretKey(String encodedSecret) { - byte[] keyBytes; - - try { - keyBytes = Base64.getDecoder().decode(encodedSecret); - } catch (IllegalArgumentException e) { - throw new IllegalStateException("JWT 서명 키는 Base64 형식이어야 합니다.", e); - } - - if (keyBytes.length < MINIMUM_HMAC_KEY_BYTES) { - throw new IllegalStateException("JWT 서명 키는 256비트 이상이어야 합니다."); - } - - return new SecretKeySpec(keyBytes, "HmacSHA256"); - } } diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/AccessTokenIssuer.java b/backend/src/main/java/withoutc/chongchong/auth/token/AccessTokenIssuer.java new file mode 100644 index 0000000..1972f13 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/AccessTokenIssuer.java @@ -0,0 +1,50 @@ +package withoutc.chongchong.auth.token; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Component; +import withoutc.chongchong.auth.config.JwtProperties; + +@Component +@RequiredArgsConstructor +public final class AccessTokenIssuer { + + private final JwtEncoder jwtEncoder; + private final JwtProperties properties; + private final Clock clock; + + public IssuedAccessToken issue(Long userId) { + validateUserId(userId); + + Instant issuedAt = clock.instant(); + Instant expiresAt = issuedAt.plus(properties.accessTokenValidity()); + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer(properties.issuer()) + .audience(List.of(properties.audience())) + .subject(userId.toString()) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .id(UUID.randomUUID().toString()) + .build(); + JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); + + String value = jwtEncoder.encode(JwtEncoderParameters.from(header, claims)) + .getTokenValue(); + + return new IssuedAccessToken(value, expiresAt); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("사용자 ID는 양수여야 합니다."); + } + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java new file mode 100644 index 0000000..9d5581f --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java @@ -0,0 +1,14 @@ +package withoutc.chongchong.auth.token; + +import java.time.Instant; + +public record IssuedAccessToken( + String value, + Instant expiresAt +) { + + @Override + public String toString() { + return "IssuedAccessToken[expiresAt=" + expiresAt + "]"; + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index d429644..de9d381 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -24,3 +24,4 @@ auth: issuer: ${AUTH_JWT_ISSUER} audience: ${AUTH_JWT_AUDIENCE} secret-base64: ${AUTH_JWT_SECRET_BASE64} + access-token-validity: ${AUTH_JWT_ACCESS_TOKEN_VALIDITY:30m} diff --git a/backend/src/test/java/withoutc/chongchong/auth/config/SecurityConfigTest.java b/backend/src/test/java/withoutc/chongchong/auth/config/JwtConfigTest.java similarity index 81% rename from backend/src/test/java/withoutc/chongchong/auth/config/SecurityConfigTest.java rename to backend/src/test/java/withoutc/chongchong/auth/config/JwtConfigTest.java index 2564602..719ab29 100644 --- a/backend/src/test/java/withoutc/chongchong/auth/config/SecurityConfigTest.java +++ b/backend/src/test/java/withoutc/chongchong/auth/config/JwtConfigTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.List; @@ -15,21 +16,23 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; -class SecurityConfigTest { +class JwtConfigTest { private static final String ISSUER = "chongchong-test"; private static final String AUDIENCE = "chongchong-test-api"; private static final byte[] SECRET = "01234567890123456789012345678901".getBytes(); private static final String ENCODED_SECRET = Base64.getEncoder().encodeToString(SECRET); + private static final Duration ACCESS_TOKEN_VALIDITY = Duration.ofMinutes(30); - private final SecurityConfig securityConfig = new SecurityConfig(); - private final JwtDecoder jwtDecoder = securityConfig.jwtDecoder(properties(ENCODED_SECRET)); + private final JwtConfig jwtConfig = new JwtConfig(); + private final JwtProperties properties = properties(ENCODED_SECRET); + private final JwtDecoder jwtDecoder = jwtConfig.jwtDecoder(jwtConfig.authJwtSecretKey(properties), properties); @Test @DisplayName("서명과 표준 Claim이 유효한 Access Token을 검증한다") @@ -81,9 +84,9 @@ void rejectAccessTokenWithInvalidAudience() { @Test @DisplayName("Base64 형식이 아닌 JWT 서명 키를 거부한다") void rejectInvalidBase64Secret() { - JwtProperties properties = properties("not-base64-secret!"); + JwtProperties invalidProperties = properties("not-base64-secret!"); - assertThatThrownBy(() -> securityConfig.jwtDecoder(properties)) + assertThatThrownBy(() -> jwtConfig.authJwtSecretKey(invalidProperties)) .isInstanceOf(IllegalStateException.class) .hasMessage("JWT 서명 키는 Base64 형식이어야 합니다."); } @@ -92,15 +95,23 @@ void rejectInvalidBase64Secret() { @DisplayName("256비트보다 짧은 JWT 서명 키를 거부한다") void rejectShortSecret() { String shortSecret = Base64.getEncoder().encodeToString("short-secret".getBytes()); - JwtProperties properties = properties(shortSecret); + JwtProperties invalidProperties = properties(shortSecret); - assertThatThrownBy(() -> securityConfig.jwtDecoder(properties)) + assertThatThrownBy(() -> jwtConfig.authJwtSecretKey(invalidProperties)) .isInstanceOf(IllegalStateException.class) .hasMessage("JWT 서명 키는 256비트 이상이어야 합니다."); } + @Test + @DisplayName("Access Token 유효 시간은 0보다 커야 한다") + void rejectNonPositiveAccessTokenValidity() { + assertThatThrownBy(() -> new JwtProperties(ISSUER, AUDIENCE, ENCODED_SECRET, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Access Token 유효 시간은 0보다 커야 합니다."); + } + private JwtProperties properties(String encodedSecret) { - return new JwtProperties(ISSUER, AUDIENCE, encodedSecret); + return new JwtProperties(ISSUER, AUDIENCE, encodedSecret, ACCESS_TOKEN_VALIDITY); } private String token( diff --git a/backend/src/test/java/withoutc/chongchong/auth/token/AccessTokenIssuerTest.java b/backend/src/test/java/withoutc/chongchong/auth/token/AccessTokenIssuerTest.java new file mode 100644 index 0000000..6691ac3 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/token/AccessTokenIssuerTest.java @@ -0,0 +1,103 @@ +package withoutc.chongchong.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Set; +import javax.crypto.SecretKey; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import withoutc.chongchong.auth.config.JwtConfig; +import withoutc.chongchong.auth.config.JwtProperties; + +class AccessTokenIssuerTest { + + private static final String ISSUER = "chongchong-test"; + private static final String AUDIENCE = "chongchong-test-api"; + private static final byte[] SECRET = "01234567890123456789012345678901".getBytes(); + private static final String ENCODED_SECRET = Base64.getEncoder().encodeToString(SECRET); + private static final Duration ACCESS_TOKEN_VALIDITY = Duration.ofMinutes(30); + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + private final JwtConfig jwtConfig = new JwtConfig(); + private final JwtProperties properties = new JwtProperties( + ISSUER, + AUDIENCE, + ENCODED_SECRET, + ACCESS_TOKEN_VALIDITY + ); + private final SecretKey secretKey = jwtConfig.authJwtSecretKey(properties); + private final JwtEncoder jwtEncoder = jwtConfig.jwtEncoder(secretKey); + private final JwtDecoder jwtDecoder = jwtConfig.jwtDecoder(secretKey, properties); + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + private final AccessTokenIssuer accessTokenIssuer = new AccessTokenIssuer(jwtEncoder, properties, clock); + + @Test + @DisplayName("기존 JwtDecoder가 검증할 수 있는 Access Token을 발급한다") + void issueAccessTokenDecodableByProductionDecoder() { + IssuedAccessToken issuedAccessToken = accessTokenIssuer.issue(1L); + + Jwt jwt = jwtDecoder.decode(issuedAccessToken.value()); + + assertThat(jwt.getSubject()).isEqualTo("1"); + assertThat(issuedAccessToken.expiresAt()).isEqualTo(NOW.plus(ACCESS_TOKEN_VALIDITY)); + } + + @Test + @DisplayName("Access Token에 인증 경계에서 정한 표준 Claim만 포함한다") + void includeExpectedClaims() { + IssuedAccessToken issuedAccessToken = accessTokenIssuer.issue(42L); + + Jwt jwt = jwtDecoder.decode(issuedAccessToken.value()); + + assertThat(jwt.getClaimAsString("iss")).isEqualTo(ISSUER); + assertThat(jwt.getAudience()).containsExactly(AUDIENCE); + assertThat(jwt.getSubject()).isEqualTo("42"); + assertThat(jwt.getIssuedAt()).isEqualTo(NOW); + assertThat(jwt.getExpiresAt()).isEqualTo(NOW.plus(ACCESS_TOKEN_VALIDITY)); + assertThat(jwt.getId()).isNotBlank(); + assertThat(jwt.getClaims().keySet()) + .containsExactlyInAnyOrderElementsOf(Set.of("iss", "aud", "sub", "iat", "exp", "jti")); + } + + @Test + @DisplayName("Access Token을 발급할 때마다 다른 jti를 사용한다") + void issueUniqueJwtId() { + Jwt first = jwtDecoder.decode(accessTokenIssuer.issue(1L).value()); + Jwt second = jwtDecoder.decode(accessTokenIssuer.issue(1L).value()); + + assertThat(first.getId()).isNotEqualTo(second.getId()); + } + + @ParameterizedTest + @NullSource + @ValueSource(longs = {0L, -1L}) + @DisplayName("양수가 아닌 사용자 ID로 Access Token을 발급하지 않는다") + void rejectInvalidUserId(Long userId) { + assertThatThrownBy(() -> accessTokenIssuer.issue(userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 양수여야 합니다."); + } + + @Test + @DisplayName("Access Token 결과를 문자열로 표현할 때 Token 원문을 노출하지 않는다") + void hideTokenValueFromStringRepresentation() { + IssuedAccessToken issuedAccessToken = accessTokenIssuer.issue(1L); + + assertThat(issuedAccessToken.toString()) + .doesNotContain(issuedAccessToken.value()) + .contains(issuedAccessToken.expiresAt().toString()); + } +} diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml index bca85a1..6302cff 100644 --- a/backend/src/test/resources/application-test.yaml +++ b/backend/src/test/resources/application-test.yaml @@ -19,3 +19,4 @@ auth: issuer: chongchong-test audience: chongchong-test-api secret-base64: MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= + access-token-validity: 30m From cf9b521972b4a7794bcbf01a3fbda10c7e7ec370 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:04:30 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20Refresh=20Token=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=ED=95=B4=EC=8B=9C=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/config/RefreshTokenConfig.java | 18 +++++ .../auth/config/RefreshTokenProperties.java | 19 ++++++ .../auth/token/HashedRefreshToken.java | 19 ++++++ .../auth/token/RawRefreshToken.java | 15 ++++ .../auth/token/RefreshTokenGenerator.java | 33 +++++++++ .../auth/token/RefreshTokenHasher.java | 32 +++++++++ backend/src/main/resources/application.yaml | 2 + .../config/RefreshTokenPropertiesTest.java | 43 ++++++++++++ .../auth/token/RefreshTokenGeneratorTest.java | 50 ++++++++++++++ .../auth/token/RefreshTokenHasherTest.java | 68 +++++++++++++++++++ .../src/test/resources/application-test.yaml | 2 + 11 files changed, 301 insertions(+) create mode 100644 backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenConfig.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenGenerator.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/config/RefreshTokenPropertiesTest.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenGeneratorTest.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenHasherTest.java diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenConfig.java b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenConfig.java new file mode 100644 index 0000000..0c0ea80 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenConfig.java @@ -0,0 +1,18 @@ +package withoutc.chongchong.auth.config; + +import java.security.SecureRandom; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RefreshTokenProperties.class) +public class RefreshTokenConfig { + + public static final String REFRESH_TOKEN_SECURE_RANDOM = "refreshTokenSecureRandom"; + + @Bean(REFRESH_TOKEN_SECURE_RANDOM) + SecureRandom refreshTokenSecureRandom() { + return new SecureRandom(); + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java new file mode 100644 index 0000000..1f71038 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java @@ -0,0 +1,19 @@ +package withoutc.chongchong.auth.config; + +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "auth.refresh-token") +public record RefreshTokenProperties( + @NotNull Duration validity +) { + + public RefreshTokenProperties { + if (validity == null || validity.isZero() || validity.isNegative()) { + throw new IllegalArgumentException("Refresh Token 유효 시간은 0보다 커야 합니다."); + } + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java new file mode 100644 index 0000000..e74c509 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java @@ -0,0 +1,19 @@ +package withoutc.chongchong.auth.token; + +import java.util.regex.Pattern; + +public record HashedRefreshToken(String value) { + + private static final Pattern SHA_256_HEX_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + public HashedRefreshToken { + if (value == null || !SHA_256_HEX_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Refresh Token 해시는 64자리 소문자 16진수여야 합니다."); + } + } + + @Override + public String toString() { + return "HashedRefreshToken[value=REDACTED]"; + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java new file mode 100644 index 0000000..119f8f4 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java @@ -0,0 +1,15 @@ +package withoutc.chongchong.auth.token; + +public record RawRefreshToken(String value) { + + public RawRefreshToken { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Refresh Token 원문은 비어 있을 수 없습니다."); + } + } + + @Override + public String toString() { + return "RawRefreshToken[value=REDACTED]"; + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenGenerator.java b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenGenerator.java new file mode 100644 index 0000000..eaf997b --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenGenerator.java @@ -0,0 +1,33 @@ +package withoutc.chongchong.auth.token; + +import static withoutc.chongchong.auth.config.RefreshTokenConfig.REFRESH_TOKEN_SECURE_RANDOM; + +import java.security.SecureRandom; +import java.util.Base64; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +@Component +public final class RefreshTokenGenerator { + + private static final int REFRESH_TOKEN_BYTES = 32; + + private final SecureRandom secureRandom; + + public RefreshTokenGenerator( + @Qualifier(REFRESH_TOKEN_SECURE_RANDOM) SecureRandom secureRandom + ) { + this.secureRandom = secureRandom; + } + + public RawRefreshToken generate() { + byte[] randomBytes = new byte[REFRESH_TOKEN_BYTES]; + secureRandom.nextBytes(randomBytes); + + String value = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(randomBytes); + + return new RawRefreshToken(value); + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java new file mode 100644 index 0000000..3a4030a --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java @@ -0,0 +1,32 @@ +package withoutc.chongchong.auth.token; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +@Component +public final class RefreshTokenHasher { + + private static final String HASH_ALGORITHM = "SHA-256"; + + public HashedRefreshToken hash(RawRefreshToken rawRefreshToken) { + if (rawRefreshToken == null) { + throw new IllegalArgumentException("Refresh Token 원문은 필수입니다."); + } + + byte[] hashBytes = messageDigest().digest(rawRefreshToken.value().getBytes(StandardCharsets.UTF_8)); + String hashValue = HexFormat.of().formatHex(hashBytes); + + return new HashedRefreshToken(hashValue); + } + + private MessageDigest messageDigest() { + try { + return MessageDigest.getInstance(HASH_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 해시 알고리즘을 사용할 수 없습니다.", e); + } + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index de9d381..822cbfe 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -25,3 +25,5 @@ auth: audience: ${AUTH_JWT_AUDIENCE} secret-base64: ${AUTH_JWT_SECRET_BASE64} access-token-validity: ${AUTH_JWT_ACCESS_TOKEN_VALIDITY:30m} + refresh-token: + validity: ${AUTH_REFRESH_TOKEN_VALIDITY:30d} diff --git a/backend/src/test/java/withoutc/chongchong/auth/config/RefreshTokenPropertiesTest.java b/backend/src/test/java/withoutc/chongchong/auth/config/RefreshTokenPropertiesTest.java new file mode 100644 index 0000000..6863bb7 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/config/RefreshTokenPropertiesTest.java @@ -0,0 +1,43 @@ +package withoutc.chongchong.auth.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RefreshTokenPropertiesTest { + + @Test + @DisplayName("양수인 Refresh Token 유효 시간을 허용한다") + void allowPositiveValidity() { + RefreshTokenProperties properties = new RefreshTokenProperties(Duration.ofDays(30)); + + assertThat(properties.validity()).isEqualTo(Duration.ofDays(30)); + } + + @Test + @DisplayName("누락된 Refresh Token 유효 시간을 거부한다") + void rejectNullValidity() { + assertThatThrownBy(() -> new RefreshTokenProperties(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 유효 시간은 0보다 커야 합니다."); + } + + @Test + @DisplayName("0인 Refresh Token 유효 시간을 거부한다") + void rejectZeroValidity() { + assertThatThrownBy(() -> new RefreshTokenProperties(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 유효 시간은 0보다 커야 합니다."); + } + + @Test + @DisplayName("음수인 Refresh Token 유효 시간을 거부한다") + void rejectNegativeValidity() { + assertThatThrownBy(() -> new RefreshTokenProperties(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 유효 시간은 0보다 커야 합니다."); + } +} diff --git a/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenGeneratorTest.java b/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenGeneratorTest.java new file mode 100644 index 0000000..d1d51f8 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenGeneratorTest.java @@ -0,0 +1,50 @@ +package withoutc.chongchong.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Base64; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RefreshTokenGeneratorTest { + + private final RefreshTokenGenerator refreshTokenGenerator = new RefreshTokenGenerator(new SecureRandom()); + + @Test + @DisplayName("256비트 URL-safe Refresh Token을 padding 없이 생성한다") + void generateUrlSafeRefreshToken() { + RawRefreshToken rawRefreshToken = refreshTokenGenerator.generate(); + + assertThat(rawRefreshToken.value()).matches("[A-Za-z0-9_-]{43}"); + assertThat(Base64.getUrlDecoder().decode(rawRefreshToken.value())).hasSize(32); + } + + @Test + @DisplayName("Refresh Token을 연속으로 생성하면 서로 다른 값을 만든다") + void generateDifferentRefreshTokens() { + RawRefreshToken first = refreshTokenGenerator.generate(); + RawRefreshToken second = refreshTokenGenerator.generate(); + + assertThat(first).isNotEqualTo(second); + } + + @Test + @DisplayName("Refresh Token 원문을 문자열 표현에 노출하지 않는다") + void hideRawRefreshTokenFromStringRepresentation() { + RawRefreshToken rawRefreshToken = refreshTokenGenerator.generate(); + + assertThat(rawRefreshToken.toString()) + .doesNotContain(rawRefreshToken.value()) + .contains("REDACTED"); + } + + @Test + @DisplayName("비어 있는 Refresh Token 원문을 허용하지 않는다") + void rejectBlankRawRefreshToken() { + assertThatThrownBy(() -> new RawRefreshToken(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 원문은 비어 있을 수 없습니다."); + } +} diff --git a/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenHasherTest.java b/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenHasherTest.java new file mode 100644 index 0000000..2cc7081 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/token/RefreshTokenHasherTest.java @@ -0,0 +1,68 @@ +package withoutc.chongchong.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RefreshTokenHasherTest { + + private final RefreshTokenHasher refreshTokenHasher = new RefreshTokenHasher(); + + @Test + @DisplayName("Refresh Token 원문을 SHA-256 소문자 16진수로 해시한다") + void hashRefreshTokenWithSha256() { + HashedRefreshToken hashedRefreshToken = refreshTokenHasher.hash(new RawRefreshToken("abc")); + + assertThat(hashedRefreshToken.value()) + .isEqualTo("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + .matches("[0-9a-f]{64}"); + } + + @Test + @DisplayName("같은 Refresh Token 원문은 같은 해시를 만든다") + void hashSameRawRefreshTokenDeterministically() { + RawRefreshToken rawRefreshToken = new RawRefreshToken("same-refresh-token"); + + HashedRefreshToken first = refreshTokenHasher.hash(rawRefreshToken); + HashedRefreshToken second = refreshTokenHasher.hash(rawRefreshToken); + + assertThat(first).isEqualTo(second); + } + + @Test + @DisplayName("다른 Refresh Token 원문은 다른 해시를 만든다") + void hashDifferentRawRefreshTokensDifferently() { + HashedRefreshToken first = refreshTokenHasher.hash(new RawRefreshToken("first-refresh-token")); + HashedRefreshToken second = refreshTokenHasher.hash(new RawRefreshToken("second-refresh-token")); + + assertThat(first).isNotEqualTo(second); + } + + @Test + @DisplayName("Refresh Token 해시를 문자열 표현에 노출하지 않는다") + void hideRefreshTokenHashFromStringRepresentation() { + HashedRefreshToken hashedRefreshToken = refreshTokenHasher.hash(new RawRefreshToken("refresh-token")); + + assertThat(hashedRefreshToken.toString()) + .doesNotContain(hashedRefreshToken.value()) + .contains("REDACTED"); + } + + @Test + @DisplayName("Refresh Token 원문 없이 해시하지 않는다") + void rejectNullRawRefreshToken() { + assertThatThrownBy(() -> refreshTokenHasher.hash(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 원문은 필수입니다."); + } + + @Test + @DisplayName("SHA-256 형식이 아닌 Refresh Token 해시를 허용하지 않는다") + void rejectInvalidRefreshTokenHash() { + assertThatThrownBy(() -> new HashedRefreshToken("invalid-hash")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh Token 해시는 64자리 소문자 16진수여야 합니다."); + } +} diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml index 6302cff..1725bb0 100644 --- a/backend/src/test/resources/application-test.yaml +++ b/backend/src/test/resources/application-test.yaml @@ -20,3 +20,5 @@ auth: audience: chongchong-test-api secret-base64: MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= access-token-validity: 30m + refresh-token: + validity: 30d From f48df47d194b2e566f20c6ca7de1cb4902ea595b Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:55:17 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongchong/auth/entity/AuthSession.java | 111 +++++++++++ .../entity/HashedRefreshTokenConverter.java | 25 +++ .../repository/AuthSessionRepository.java | 27 +++ .../user/repository/UserRepository.java | 9 + .../auth/entity/AuthSessionTest.java | 101 ++++++++++ .../repository/AuthSessionRepositoryTest.java | 172 ++++++++++++++++++ 6 files changed, 445 insertions(+) create mode 100644 backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/entity/HashedRefreshTokenConverter.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/repository/AuthSessionRepository.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/entity/AuthSessionTest.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/repository/AuthSessionRepositoryTest.java diff --git a/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java b/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java new file mode 100644 index 0000000..3d9627b --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java @@ -0,0 +1,111 @@ +package withoutc.chongchong.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.global.persistence.BaseEntity; +import withoutc.chongchong.user.entity.User; + +@Entity +@Table( + name = "auth_sessions", + uniqueConstraints = { + @UniqueConstraint(name = "uk_auth_sessions_user_id", columnNames = "user_id"), + @UniqueConstraint( + name = "uk_auth_sessions_refresh_token_hash", + columnNames = "refresh_token_hash" + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuthSession extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Convert(converter = HashedRefreshTokenConverter.class) + @Column(name = "refresh_token_hash", nullable = false, length = 64) + private HashedRefreshToken refreshTokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + public static AuthSession create( + User user, + HashedRefreshToken refreshTokenHash, + Instant expiresAt + ) { + validate(user, refreshTokenHash, expiresAt); + return new AuthSession(user, refreshTokenHash, expiresAt); + } + + private AuthSession( + User user, + HashedRefreshToken refreshTokenHash, + Instant expiresAt + ) { + this.user = user; + this.refreshTokenHash = refreshTokenHash; + this.expiresAt = expiresAt; + } + + public void replaceRefreshToken( + HashedRefreshToken refreshTokenHash, + Instant expiresAt + ) { + validateRefreshTokenHash(refreshTokenHash); + validateExpiresAt(expiresAt); + this.refreshTokenHash = refreshTokenHash; + this.expiresAt = expiresAt; + } + + public boolean isExpiredAt(Instant referenceTime) { + if (referenceTime == null) { + throw new IllegalArgumentException("만료 여부를 확인할 기준 시각은 필수입니다."); + } + return !referenceTime.isBefore(expiresAt); + } + + private static void validate( + User user, + HashedRefreshToken refreshTokenHash, + Instant expiresAt + ) { + if (user == null) { + throw new IllegalArgumentException("인증 세션의 사용자는 필수입니다."); + } + validateRefreshTokenHash(refreshTokenHash); + validateExpiresAt(expiresAt); + } + + private static void validateRefreshTokenHash(HashedRefreshToken refreshTokenHash) { + if (refreshTokenHash == null) { + throw new IllegalArgumentException("인증 세션의 Refresh Token 해시는 필수입니다."); + } + } + + private static void validateExpiresAt(Instant expiresAt) { + if (expiresAt == null) { + throw new IllegalArgumentException("인증 세션의 만료 시각은 필수입니다."); + } + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/entity/HashedRefreshTokenConverter.java b/backend/src/main/java/withoutc/chongchong/auth/entity/HashedRefreshTokenConverter.java new file mode 100644 index 0000000..bfe761f --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/entity/HashedRefreshTokenConverter.java @@ -0,0 +1,25 @@ +package withoutc.chongchong.auth.entity; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import withoutc.chongchong.auth.token.HashedRefreshToken; + +@Converter +public class HashedRefreshTokenConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(HashedRefreshToken attribute) { + if (attribute == null) { + return null; + } + return attribute.value(); + } + + @Override + public HashedRefreshToken convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + return new HashedRefreshToken(dbData); + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/repository/AuthSessionRepository.java b/backend/src/main/java/withoutc/chongchong/auth/repository/AuthSessionRepository.java new file mode 100644 index 0000000..e79eac1 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/repository/AuthSessionRepository.java @@ -0,0 +1,27 @@ +package withoutc.chongchong.auth.repository; + +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import withoutc.chongchong.auth.entity.AuthSession; +import withoutc.chongchong.auth.token.HashedRefreshToken; + +@Repository +public interface AuthSessionRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT authSession + FROM AuthSession authSession + WHERE authSession.refreshTokenHash = :refreshTokenHash + """) + Optional findByRefreshTokenHashForUpdate( + @Param("refreshTokenHash") HashedRefreshToken refreshTokenHash + ); +} diff --git a/backend/src/main/java/withoutc/chongchong/user/repository/UserRepository.java b/backend/src/main/java/withoutc/chongchong/user/repository/UserRepository.java index e6f4af0..e85b34f 100644 --- a/backend/src/main/java/withoutc/chongchong/user/repository/UserRepository.java +++ b/backend/src/main/java/withoutc/chongchong/user/repository/UserRepository.java @@ -1,9 +1,18 @@ package withoutc.chongchong.user.repository; +import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import withoutc.chongchong.user.entity.User; @Repository public interface UserRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT targetUser FROM User targetUser WHERE targetUser.id = :userId") + Optional findByIdForUpdate(@Param("userId") Long userId); } diff --git a/backend/src/test/java/withoutc/chongchong/auth/entity/AuthSessionTest.java b/backend/src/test/java/withoutc/chongchong/auth/entity/AuthSessionTest.java new file mode 100644 index 0000000..9dcbeee --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/entity/AuthSessionTest.java @@ -0,0 +1,101 @@ +package withoutc.chongchong.auth.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.user.entity.User; + +class AuthSessionTest { + + private static final Instant EXPIRES_AT = Instant.parse("2026-09-18T00:00:00Z"); + private static final HashedRefreshToken REFRESH_TOKEN_HASH = new HashedRefreshToken("a".repeat(64)); + + @Test + @DisplayName("사용자와 Refresh Token 해시 및 만료 시각으로 인증 세션을 생성한다") + void createAuthSession() { + User user = User.create("총총이", null); + + AuthSession authSession = AuthSession.create(user, REFRESH_TOKEN_HASH, EXPIRES_AT); + + assertThat(authSession.getUser()).isSameAs(user); + assertThat(authSession.getRefreshTokenHash()).isEqualTo(REFRESH_TOKEN_HASH); + assertThat(authSession.getExpiresAt()).isEqualTo(EXPIRES_AT); + } + + @Test + @DisplayName("사용자 없이 인증 세션을 생성하지 않는다") + void rejectNullUser() { + assertThatThrownBy(() -> AuthSession.create(null, REFRESH_TOKEN_HASH, EXPIRES_AT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("인증 세션의 사용자는 필수입니다."); + } + + @Test + @DisplayName("Refresh Token 해시 없이 인증 세션을 생성하지 않는다") + void rejectNullRefreshTokenHash() { + User user = User.create("총총이", null); + + assertThatThrownBy(() -> AuthSession.create(user, null, EXPIRES_AT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("인증 세션의 Refresh Token 해시는 필수입니다."); + } + + @Test + @DisplayName("만료 시각 없이 인증 세션을 생성하지 않는다") + void rejectNullExpiresAt() { + User user = User.create("총총이", null); + + assertThatThrownBy(() -> AuthSession.create(user, REFRESH_TOKEN_HASH, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("인증 세션의 만료 시각은 필수입니다."); + } + + @Test + @DisplayName("기준 시각이 만료 시각과 같거나 이후이면 인증 세션이 만료되었다") + void determineExpirationAtBoundary() { + AuthSession authSession = AuthSession.create( + User.create("총총이", null), + REFRESH_TOKEN_HASH, + EXPIRES_AT + ); + + assertThat(authSession.isExpiredAt(EXPIRES_AT.minusNanos(1))).isFalse(); + assertThat(authSession.isExpiredAt(EXPIRES_AT)).isTrue(); + assertThat(authSession.isExpiredAt(EXPIRES_AT.plusNanos(1))).isTrue(); + } + + @Test + @DisplayName("기준 시각 없이 인증 세션의 만료 여부를 확인하지 않는다") + void rejectNullExpirationReferenceTime() { + AuthSession authSession = AuthSession.create( + User.create("총총이", null), + REFRESH_TOKEN_HASH, + EXPIRES_AT + ); + + assertThatThrownBy(() -> authSession.isExpiredAt(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("만료 여부를 확인할 기준 시각은 필수입니다."); + } + + @Test + @DisplayName("인증 세션의 Refresh Token 해시와 만료 시각을 교체한다") + void replaceRefreshToken() { + AuthSession authSession = AuthSession.create( + User.create("총총이", null), + REFRESH_TOKEN_HASH, + EXPIRES_AT + ); + HashedRefreshToken newRefreshTokenHash = new HashedRefreshToken("b".repeat(64)); + Instant newExpiresAt = EXPIRES_AT.plusSeconds(60); + + authSession.replaceRefreshToken(newRefreshTokenHash, newExpiresAt); + + assertThat(authSession.getRefreshTokenHash()).isEqualTo(newRefreshTokenHash); + assertThat(authSession.getExpiresAt()).isEqualTo(newExpiresAt); + } +} diff --git a/backend/src/test/java/withoutc/chongchong/auth/repository/AuthSessionRepositoryTest.java b/backend/src/test/java/withoutc/chongchong/auth/repository/AuthSessionRepositoryTest.java new file mode 100644 index 0000000..f2f2289 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/repository/AuthSessionRepositoryTest.java @@ -0,0 +1,172 @@ +package withoutc.chongchong.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import withoutc.chongchong.auth.entity.AuthSession; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.auth.token.RawRefreshToken; +import withoutc.chongchong.auth.token.RefreshTokenHasher; +import withoutc.chongchong.user.entity.User; +import withoutc.chongchong.user.repository.UserRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AuthSessionRepositoryTest { + + private static final Instant EXPIRES_AT = Instant.parse("2026-09-18T00:00:00Z"); + private static final HashedRefreshToken REFRESH_TOKEN_HASH = new HashedRefreshToken("a".repeat(64)); + + @Autowired + private AuthSessionRepository authSessionRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenHasher refreshTokenHasher; + + @Autowired + private EntityManager entityManager; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("사용자와 Refresh Token 해시 및 만료 시각을 저장하고 조회한다") + void saveAndFindAuthSession() { + User user = saveUser("총총이"); + AuthSession saved = authSessionRepository.saveAndFlush( + AuthSession.create(user, REFRESH_TOKEN_HASH, EXPIRES_AT) + ); + entityManager.clear(); + + AuthSession found = authSessionRepository.findByUserId(user.getId()).orElseThrow(); + + assertThat(found.getId()).isEqualTo(saved.getId()); + assertThat(found.getUser().getId()).isEqualTo(user.getId()); + assertThat(found.getRefreshTokenHash()).isEqualTo(REFRESH_TOKEN_HASH); + assertThat(found.getExpiresAt()).isEqualTo(EXPIRES_AT); + assertThat(countRowsWithAuditingTimestamps(saved.getId())).isOne(); + } + + @Test + @DisplayName("Refresh Token 원문 대신 SHA-256 해시만 데이터베이스에 저장한다") + void storeOnlyHashedRefreshToken() { + User user = saveUser("총총이"); + RawRefreshToken rawRefreshToken = new RawRefreshToken("raw-refresh-token-for-persistence-test"); + HashedRefreshToken hashedRefreshToken = refreshTokenHasher.hash(rawRefreshToken); + AuthSession saved = authSessionRepository.saveAndFlush( + AuthSession.create(user, hashedRefreshToken, EXPIRES_AT) + ); + + String storedValue = jdbcTemplate.queryForObject( + "SELECT refresh_token_hash FROM auth_sessions WHERE id = ?", + String.class, + saved.getId() + ); + + assertThat(storedValue) + .isEqualTo(hashedRefreshToken.value()) + .isNotEqualTo(rawRefreshToken.value()); + } + + @Test + @DisplayName("한 사용자에게 두 개의 인증 세션을 저장하지 않는다") + void rejectDuplicateUserSession() { + User user = saveUser("총총이"); + authSessionRepository.saveAndFlush(AuthSession.create(user, REFRESH_TOKEN_HASH, EXPIRES_AT)); + AuthSession duplicate = AuthSession.create( + user, + new HashedRefreshToken("b".repeat(64)), + EXPIRES_AT + ); + + assertThatThrownBy(() -> authSessionRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("같은 Refresh Token 해시를 여러 인증 세션에 저장하지 않는다") + void rejectDuplicateRefreshTokenHash() { + User firstUser = saveUser("첫 번째 사용자"); + User secondUser = saveUser("두 번째 사용자"); + authSessionRepository.saveAndFlush(AuthSession.create(firstUser, REFRESH_TOKEN_HASH, EXPIRES_AT)); + AuthSession duplicate = AuthSession.create(secondUser, REFRESH_TOKEN_HASH, EXPIRES_AT); + + assertThatThrownBy(() -> authSessionRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("Refresh Token 해시 컬럼은 64자이고 NULL을 허용하지 않는다") + void defineRefreshTokenHashColumnConstraints() { + Long maximumLength = jdbcTemplate.queryForObject(""" + SELECT CHARACTER_MAXIMUM_LENGTH + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'PUBLIC' + AND TABLE_NAME = 'AUTH_SESSIONS' + AND COLUMN_NAME = 'REFRESH_TOKEN_HASH' + """, Long.class); + String nullable = jdbcTemplate.queryForObject(""" + SELECT IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'PUBLIC' + AND TABLE_NAME = 'AUTH_SESSIONS' + AND COLUMN_NAME = 'REFRESH_TOKEN_HASH' + """, String.class); + + assertThat(maximumLength).isEqualTo(64L); + assertThat(nullable).isEqualTo("NO"); + } + + @Test + @DisplayName("사용자 행을 비관적 쓰기 잠금으로 조회한다") + void findUserForUpdate() { + User saved = saveUser("총총이"); + entityManager.clear(); + + User locked = userRepository.findByIdForUpdate(saved.getId()).orElseThrow(); + + assertThat(entityManager.getLockMode(locked)).isEqualTo(LockModeType.PESSIMISTIC_WRITE); + } + + @Test + @DisplayName("Refresh Token 해시로 인증 세션을 비관적 쓰기 잠금 조회한다") + void findAuthSessionByRefreshTokenHashForUpdate() { + User user = saveUser("총총이"); + authSessionRepository.saveAndFlush(AuthSession.create(user, REFRESH_TOKEN_HASH, EXPIRES_AT)); + entityManager.clear(); + + AuthSession locked = authSessionRepository.findByRefreshTokenHashForUpdate(REFRESH_TOKEN_HASH) + .orElseThrow(); + + assertThat(entityManager.getLockMode(locked)).isEqualTo(LockModeType.PESSIMISTIC_WRITE); + } + + private User saveUser(String name) { + return userRepository.saveAndFlush(User.create(name, null)); + } + + private Integer countRowsWithAuditingTimestamps(Long authSessionId) { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM auth_sessions + WHERE id = ? + AND created_at IS NOT NULL + AND updated_at IS NOT NULL + """, Integer.class, authSessionId); + } +} From e8531822dfe2143f4cca13cd8e0f903fa8a8d5bf Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:19:14 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=8B=A8=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=84=B8=EC=85=98=20=EA=B8=B0=EB=B0=98=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/exception/AuthErrorCode.java | 4 +- .../auth/exception/AuthException.java | 10 + .../auth/service/AuthTokenService.java | 62 ++++++ .../auth/token/IssuedTokenPair.java | 28 +++ .../AuthTokenServiceConcurrencyTest.java | 97 ++++++++++ .../auth/service/AuthTokenServiceTest.java | 180 ++++++++++++++++++ 6 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/withoutc/chongchong/auth/exception/AuthException.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java create mode 100644 backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java create mode 100644 backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java diff --git a/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java index 712afa1..7d0373d 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java +++ b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java @@ -11,7 +11,9 @@ public enum AuthErrorCode implements ErrorCode { AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "인증이 필요합니다."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "요청한 작업을 수행할 권한이 없습니다."); + ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "요청한 작업을 수행할 권한이 없습니다."), + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/backend/src/main/java/withoutc/chongchong/auth/exception/AuthException.java b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthException.java new file mode 100644 index 0000000..a3ed0e0 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthException.java @@ -0,0 +1,10 @@ +package withoutc.chongchong.auth.exception; + +import withoutc.chongchong.global.exception.BusinessException; + +public class AuthException extends BusinessException { + + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java new file mode 100644 index 0000000..f07e97d --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java @@ -0,0 +1,62 @@ +package withoutc.chongchong.auth.service; + +import java.time.Clock; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import withoutc.chongchong.auth.config.RefreshTokenProperties; +import withoutc.chongchong.auth.entity.AuthSession; +import withoutc.chongchong.auth.exception.AuthErrorCode; +import withoutc.chongchong.auth.exception.AuthException; +import withoutc.chongchong.auth.repository.AuthSessionRepository; +import withoutc.chongchong.auth.token.AccessTokenIssuer; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.auth.token.IssuedAccessToken; +import withoutc.chongchong.auth.token.IssuedTokenPair; +import withoutc.chongchong.auth.token.RawRefreshToken; +import withoutc.chongchong.auth.token.RefreshTokenGenerator; +import withoutc.chongchong.auth.token.RefreshTokenHasher; +import withoutc.chongchong.user.entity.User; +import withoutc.chongchong.user.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class AuthTokenService { + + private final UserRepository userRepository; + private final AuthSessionRepository authSessionRepository; + private final AccessTokenIssuer accessTokenIssuer; + private final RefreshTokenGenerator refreshTokenGenerator; + private final RefreshTokenHasher refreshTokenHasher; + private final RefreshTokenProperties refreshTokenProperties; + private final Clock clock; + + @Transactional + public IssuedTokenPair issue(Long userId) { + validateUserId(userId); + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); + + IssuedAccessToken accessToken = accessTokenIssuer.issue(userId); + RawRefreshToken refreshToken = refreshTokenGenerator.generate(); + HashedRefreshToken refreshTokenHash = refreshTokenHasher.hash(refreshToken); + Instant refreshTokenExpiresAt = clock.instant().plus(refreshTokenProperties.validity()); + + authSessionRepository.findByUserId(userId) + .ifPresentOrElse( + authSession -> authSession.replaceRefreshToken(refreshTokenHash, refreshTokenExpiresAt), + () -> authSessionRepository.save( + AuthSession.create(user, refreshTokenHash, refreshTokenExpiresAt) + ) + ); + + return new IssuedTokenPair(accessToken, refreshToken, refreshTokenExpiresAt); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("사용자 ID는 양수여야 합니다."); + } + } +} diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java new file mode 100644 index 0000000..a98b0e2 --- /dev/null +++ b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java @@ -0,0 +1,28 @@ +package withoutc.chongchong.auth.token; + +import java.time.Instant; + +public record IssuedTokenPair( + IssuedAccessToken accessToken, + RawRefreshToken refreshToken, + Instant refreshTokenExpiresAt +) { + + public IssuedTokenPair { + if (accessToken == null) { + throw new IllegalArgumentException("발급된 Access Token은 필수입니다."); + } + if (refreshToken == null) { + throw new IllegalArgumentException("발급된 Refresh Token은 필수입니다."); + } + if (refreshTokenExpiresAt == null) { + throw new IllegalArgumentException("Refresh Token 만료 시각은 필수입니다."); + } + } + + @Override + public String toString() { + return "IssuedTokenPair[accessTokenExpiresAt=" + accessToken.expiresAt() + + ", refreshToken=REDACTED, refreshTokenExpiresAt=" + refreshTokenExpiresAt + "]"; + } +} diff --git a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java new file mode 100644 index 0000000..a2db161 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java @@ -0,0 +1,97 @@ +package withoutc.chongchong.auth.service; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import withoutc.chongchong.auth.entity.AuthSession; +import withoutc.chongchong.auth.repository.AuthSessionRepository; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.auth.token.IssuedTokenPair; +import withoutc.chongchong.auth.token.RefreshTokenHasher; +import withoutc.chongchong.support.TestDatabaseCleaner; +import withoutc.chongchong.user.entity.User; +import withoutc.chongchong.user.repository.UserRepository; + +@SpringBootTest +@ActiveProfiles("test") +class AuthTokenServiceConcurrencyTest { + + @Autowired + private AuthTokenService authTokenService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionRepository authSessionRepository; + + @Autowired + private RefreshTokenHasher refreshTokenHasher; + + @Autowired + private TestDatabaseCleaner databaseCleaner; + + @BeforeEach + @AfterEach + void cleanDatabase() { + databaseCleaner.clean(); + } + + @Test + @DisplayName("같은 사용자가 동시에 Token을 발급받아도 활성 인증 세션은 하나다") + void keepSingleSessionOnConcurrentIssue() throws Exception { + User user = userRepository.saveAndFlush(User.create("총총이", null)); + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + try { + Future firstFuture = executorService.submit( + () -> issueAfterSignal(user.getId(), ready, start) + ); + Future secondFuture = executorService.submit( + () -> issueAfterSignal(user.getId(), ready, start) + ); + + assertThat(ready.await(5, SECONDS)).isTrue(); + start.countDown(); + + IssuedTokenPair first = firstFuture.get(10, SECONDS); + IssuedTokenPair second = secondFuture.get(10, SECONDS); + HashedRefreshToken firstHash = refreshTokenHasher.hash(first.refreshToken()); + HashedRefreshToken secondHash = refreshTokenHasher.hash(second.refreshToken()); + AuthSession currentSession = authSessionRepository.findByUserId(user.getId()).orElseThrow(); + + assertThat(first.refreshToken()).isNotEqualTo(second.refreshToken()); + assertThat(authSessionRepository.count()).isOne(); + assertThat(currentSession.getRefreshTokenHash()).isIn(firstHash, secondHash); + } finally { + start.countDown(); + executorService.shutdownNow(); + assertThat(executorService.awaitTermination(5, SECONDS)).isTrue(); + } + } + + private IssuedTokenPair issueAfterSignal( + Long userId, + CountDownLatch ready, + CountDownLatch start + ) throws InterruptedException { + ready.countDown(); + if (!start.await(5, SECONDS)) { + throw new IllegalStateException("동시 발급 시작 신호를 기다리는 시간이 초과되었습니다."); + } + return authTokenService.issue(userId); + } +} diff --git a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java new file mode 100644 index 0000000..5f14f0b --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java @@ -0,0 +1,180 @@ +package withoutc.chongchong.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.ActiveProfiles; +import withoutc.chongchong.auth.entity.AuthSession; +import withoutc.chongchong.auth.exception.AuthErrorCode; +import withoutc.chongchong.auth.exception.AuthException; +import withoutc.chongchong.auth.repository.AuthSessionRepository; +import withoutc.chongchong.auth.token.HashedRefreshToken; +import withoutc.chongchong.auth.token.IssuedTokenPair; +import withoutc.chongchong.auth.token.RefreshTokenHasher; +import withoutc.chongchong.support.TestDatabaseCleaner; +import withoutc.chongchong.user.entity.User; +import withoutc.chongchong.user.repository.UserRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Import(AuthTokenServiceTest.FixedClockConfig.class) +class AuthTokenServiceTest { + + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + @Autowired + private AuthTokenService authTokenService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionRepository authSessionRepository; + + @Autowired + private RefreshTokenHasher refreshTokenHasher; + + @Autowired + private JwtDecoder jwtDecoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TestDatabaseCleaner databaseCleaner; + + @BeforeEach + @AfterEach + void cleanDatabase() { + databaseCleaner.clean(); + } + + @Test + @DisplayName("기존 사용자에게 Access Token과 Refresh Token을 발급하고 인증 세션을 저장한다") + void issueTokenPairAndStoreAuthSession() { + User user = saveUser("총총이"); + + IssuedTokenPair tokenPair = authTokenService.issue(user.getId()); + + Jwt accessToken = jwtDecoder.decode(tokenPair.accessToken().value()); + HashedRefreshToken expectedHash = refreshTokenHasher.hash(tokenPair.refreshToken()); + AuthSession authSession = authSessionRepository.findByUserId(user.getId()).orElseThrow(); + String storedHash = findStoredRefreshTokenHash(authSession.getId()); + + assertThat(accessToken.getSubject()).isEqualTo(user.getId().toString()); + assertThat(tokenPair.accessToken().expiresAt()).isEqualTo(NOW.plus(Duration.ofMinutes(30))); + assertThat(tokenPair.refreshTokenExpiresAt()).isEqualTo(NOW.plus(Duration.ofDays(30))); + assertThat(authSession.getRefreshTokenHash()).isEqualTo(expectedHash); + assertThat(authSession.getExpiresAt()).isEqualTo(tokenPair.refreshTokenExpiresAt()); + assertThat(storedHash) + .isEqualTo(expectedHash.value()) + .isNotEqualTo(tokenPair.refreshToken().value()); + assertThat(tokenPair.toString()) + .doesNotContain(tokenPair.accessToken().value()) + .doesNotContain(tokenPair.refreshToken().value()) + .contains("REDACTED"); + } + + @Test + @DisplayName("같은 사용자에게 다시 발급하면 기존 인증 세션을 새 Refresh Token으로 교체한다") + void replaceExistingAuthSession() { + User user = saveUser("총총이"); + IssuedTokenPair first = authTokenService.issue(user.getId()); + AuthSession firstSession = authSessionRepository.findByUserId(user.getId()).orElseThrow(); + Long sessionId = firstSession.getId(); + HashedRefreshToken firstHash = firstSession.getRefreshTokenHash(); + + IssuedTokenPair second = authTokenService.issue(user.getId()); + + AuthSession replaced = authSessionRepository.findByUserId(user.getId()).orElseThrow(); + HashedRefreshToken secondHash = refreshTokenHasher.hash(second.refreshToken()); + assertThat(authSessionRepository.count()).isOne(); + assertThat(replaced.getId()).isEqualTo(sessionId); + assertThat(replaced.getRefreshTokenHash()) + .isEqualTo(secondHash) + .isNotEqualTo(firstHash); + assertThat(second.refreshToken()).isNotEqualTo(first.refreshToken()); + } + + @Test + @DisplayName("서로 다른 사용자의 인증 세션은 독립적으로 유지한다") + void keepDifferentUserSessionsIndependently() { + User firstUser = saveUser("첫 번째 사용자"); + User secondUser = saveUser("두 번째 사용자"); + + IssuedTokenPair first = authTokenService.issue(firstUser.getId()); + IssuedTokenPair second = authTokenService.issue(secondUser.getId()); + + assertThat(authSessionRepository.count()).isEqualTo(2); + assertThat(authSessionRepository.findByUserId(firstUser.getId()).orElseThrow().getRefreshTokenHash()) + .isEqualTo(refreshTokenHasher.hash(first.refreshToken())); + assertThat(authSessionRepository.findByUserId(secondUser.getId()).orElseThrow().getRefreshTokenHash()) + .isEqualTo(refreshTokenHasher.hash(second.refreshToken())); + } + + @Test + @DisplayName("존재하지 않는 사용자에게 Token을 발급하지 않는다") + void rejectMissingUser() { + assertThatThrownBy(() -> authTokenService.issue(999L)) + .isInstanceOf(AuthException.class) + .extracting(exception -> ((AuthException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.USER_NOT_FOUND); + + assertThat(authSessionRepository.count()).isZero(); + } + + @ParameterizedTest + @NullSource + @ValueSource(longs = {0L, -1L}) + @DisplayName("양수가 아닌 사용자 ID로 Token을 발급하지 않는다") + void rejectInvalidUserId(Long userId) { + assertThatThrownBy(() -> authTokenService.issue(userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 양수여야 합니다."); + + assertThat(authSessionRepository.count()).isZero(); + } + + private User saveUser(String name) { + return userRepository.saveAndFlush(User.create(name, null)); + } + + private String findStoredRefreshTokenHash(Long authSessionId) { + return jdbcTemplate.queryForObject( + "SELECT refresh_token_hash FROM auth_sessions WHERE id = ?", + String.class, + authSessionId + ); + } + + @TestConfiguration(proxyBeanMethods = false) + static class FixedClockConfig { + + @Bean + @Primary + Clock fixedClock() { + return Clock.fixed(NOW, ZoneOffset.UTC); + } + } +} From 5c38b1f553606822a8ff71490000ae3a42bd1c26 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:18:47 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A4=80=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../withoutc/chongchong/auth/service/AuthTokenService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java index f07e97d..f760686 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java +++ b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java @@ -4,6 +4,7 @@ import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import withoutc.chongchong.auth.config.RefreshTokenProperties; import withoutc.chongchong.auth.entity.AuthSession; @@ -32,7 +33,7 @@ public class AuthTokenService { private final RefreshTokenProperties refreshTokenProperties; private final Clock clock; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public IssuedTokenPair issue(Long userId) { validateUserId(userId); User user = userRepository.findByIdForUpdate(userId) From a12bfef4c9ae209640978ae5a0e286a793545090 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:39:11 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthTokenServiceConcurrencyTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java index a2db161..b56d907 100644 --- a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java +++ b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceConcurrencyTest.java @@ -79,7 +79,7 @@ void keepSingleSessionOnConcurrentIssue() throws Exception { } finally { start.countDown(); executorService.shutdownNow(); - assertThat(executorService.awaitTermination(5, SECONDS)).isTrue(); + executorService.awaitTermination(5, SECONDS); } } From 1f6d7b884df33a52f4c8d4e8edb835d9b5f5b929 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:44:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?docs:=20ADR=20=ED=9B=84=EC=86=8D=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=B2=94=EC=9C=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0008-establish-auth-token-and-session-lifecycle.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md b/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md index 9f5e28b..bdb1597 100644 --- a/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md +++ b/backend/docs/adr/0008-establish-auth-token-and-session-lifecycle.md @@ -239,14 +239,6 @@ Access/Refresh Token을 발급한다는 1차 결정과 맞지 않아 선택하 ## 후속 작업 -- Access/Refresh Token 만료 시간을 설정 값으로 추가한다. -- 발급기와 검증기가 공유할 HMAC `SecretKey` 구성을 분리한다. -- Spring Security `JwtEncoder`와 Access Token 발급기를 구현한다. -- Refresh Token 생성기와 SHA-256 해시 컴포넌트를 구현한다. -- `AuthSession` Entity와 Repository 및 데이터베이스 제약을 구현한다. -- 사용자 행 잠금을 사용하는 Token 쌍 발급 서비스를 구현한다. -- Claim, 만료, 해시, Session 교체와 동시 발급을 테스트한다. -- 발급한 Access Token이 기존 `JwtDecoder`를 통과하는지 검증한다. - 후속 이슈에서 소셜 로그인과 자동 회원가입을 구현한다. - 후속 이슈에서 Rotation, 재발급, 로그아웃과 Cookie/CSRF 정책을 구현한다. From dc201df8ad3eddf200e61959bd3fd074662abccc Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:12:12 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20Access=20Token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B2=B0=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/token/IssuedAccessToken.java | 17 ++++++++++ .../auth/token/IssuedAccessTokenTest.java | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 backend/src/test/java/withoutc/chongchong/auth/token/IssuedAccessTokenTest.java diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java index 9d5581f..382afd4 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java +++ b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedAccessToken.java @@ -7,6 +7,23 @@ public record IssuedAccessToken( Instant expiresAt ) { + public IssuedAccessToken { + validateValue(value); + validateExpiresAt(expiresAt); + } + + private void validateValue(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("발급된 Access Token 값은 필수입니다."); + } + } + + private void validateExpiresAt(Instant expiresAt) { + if (expiresAt == null) { + throw new IllegalArgumentException("Access Token 만료 시각은 필수입니다."); + } + } + @Override public String toString() { return "IssuedAccessToken[expiresAt=" + expiresAt + "]"; diff --git a/backend/src/test/java/withoutc/chongchong/auth/token/IssuedAccessTokenTest.java b/backend/src/test/java/withoutc/chongchong/auth/token/IssuedAccessTokenTest.java new file mode 100644 index 0000000..e350736 --- /dev/null +++ b/backend/src/test/java/withoutc/chongchong/auth/token/IssuedAccessTokenTest.java @@ -0,0 +1,33 @@ +package withoutc.chongchong.auth.token; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class IssuedAccessTokenTest { + + private static final Instant EXPIRES_AT = Instant.parse("2026-08-20T00:30:00Z"); + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + @DisplayName("비어 있는 Access Token 값을 허용하지 않는다") + void rejectBlankValue(String value) { + assertThatThrownBy(() -> new IssuedAccessToken(value, EXPIRES_AT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("발급된 Access Token 값은 필수입니다."); + } + + @Test + @DisplayName("Access Token 만료 시각은 필수다") + void rejectNullExpiresAt() { + assertThatThrownBy(() -> new IssuedAccessToken("access-token", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Access Token 만료 시각은 필수입니다."); + } +} From 5b558c52b3254b421051192597d70d041a9366d8 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:17:23 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EA=B2=80=EC=A6=9D=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chongchong/auth/config/JwtProperties.java | 4 ++++ .../auth/config/RefreshTokenProperties.java | 4 ++++ .../chongchong/auth/entity/AuthSession.java | 14 +++++++++++--- .../chongchong/auth/token/HashedRefreshToken.java | 4 ++++ .../chongchong/auth/token/IssuedTokenPair.java | 12 ++++++++++++ .../chongchong/auth/token/RawRefreshToken.java | 4 ++++ .../chongchong/auth/token/RefreshTokenHasher.java | 10 +++++++--- 7 files changed, 46 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java b/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java index 121b2c8..cead76d 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java +++ b/backend/src/main/java/withoutc/chongchong/auth/config/JwtProperties.java @@ -16,6 +16,10 @@ public record JwtProperties( ) { public JwtProperties { + validateAccessTokenValidity(accessTokenValidity); + } + + private void validateAccessTokenValidity(Duration accessTokenValidity) { if (accessTokenValidity == null || accessTokenValidity.isZero() || accessTokenValidity.isNegative()) { diff --git a/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java index 1f71038..55fad2c 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java +++ b/backend/src/main/java/withoutc/chongchong/auth/config/RefreshTokenProperties.java @@ -12,6 +12,10 @@ public record RefreshTokenProperties( ) { public RefreshTokenProperties { + validateValidity(validity); + } + + private void validateValidity(Duration validity) { if (validity == null || validity.isZero() || validity.isNegative()) { throw new IllegalArgumentException("Refresh Token 유효 시간은 0보다 커야 합니다."); } diff --git a/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java b/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java index 3d9627b..f484ea3 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java +++ b/backend/src/main/java/withoutc/chongchong/auth/entity/AuthSession.java @@ -79,10 +79,14 @@ public void replaceRefreshToken( } public boolean isExpiredAt(Instant referenceTime) { + validateReferenceTime(referenceTime); + return !referenceTime.isBefore(expiresAt); + } + + private void validateReferenceTime(Instant referenceTime) { if (referenceTime == null) { throw new IllegalArgumentException("만료 여부를 확인할 기준 시각은 필수입니다."); } - return !referenceTime.isBefore(expiresAt); } private static void validate( @@ -90,11 +94,15 @@ private static void validate( HashedRefreshToken refreshTokenHash, Instant expiresAt ) { + validateUser(user); + validateRefreshTokenHash(refreshTokenHash); + validateExpiresAt(expiresAt); + } + + private static void validateUser(User user) { if (user == null) { throw new IllegalArgumentException("인증 세션의 사용자는 필수입니다."); } - validateRefreshTokenHash(refreshTokenHash); - validateExpiresAt(expiresAt); } private static void validateRefreshTokenHash(HashedRefreshToken refreshTokenHash) { diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java index e74c509..2ca3d2d 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java +++ b/backend/src/main/java/withoutc/chongchong/auth/token/HashedRefreshToken.java @@ -7,6 +7,10 @@ public record HashedRefreshToken(String value) { private static final Pattern SHA_256_HEX_PATTERN = Pattern.compile("[0-9a-f]{64}"); public HashedRefreshToken { + validateValue(value); + } + + private void validateValue(String value) { if (value == null || !SHA_256_HEX_PATTERN.matcher(value).matches()) { throw new IllegalArgumentException("Refresh Token 해시는 64자리 소문자 16진수여야 합니다."); } diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java index a98b0e2..fe716da 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java +++ b/backend/src/main/java/withoutc/chongchong/auth/token/IssuedTokenPair.java @@ -9,12 +9,24 @@ public record IssuedTokenPair( ) { public IssuedTokenPair { + validateAccessToken(accessToken); + validateRefreshToken(refreshToken); + validateRefreshTokenExpiresAt(refreshTokenExpiresAt); + } + + private void validateAccessToken(IssuedAccessToken accessToken) { if (accessToken == null) { throw new IllegalArgumentException("발급된 Access Token은 필수입니다."); } + } + + private void validateRefreshToken(RawRefreshToken refreshToken) { if (refreshToken == null) { throw new IllegalArgumentException("발급된 Refresh Token은 필수입니다."); } + } + + private void validateRefreshTokenExpiresAt(Instant refreshTokenExpiresAt) { if (refreshTokenExpiresAt == null) { throw new IllegalArgumentException("Refresh Token 만료 시각은 필수입니다."); } diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java b/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java index 119f8f4..ddd46dd 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java +++ b/backend/src/main/java/withoutc/chongchong/auth/token/RawRefreshToken.java @@ -3,6 +3,10 @@ public record RawRefreshToken(String value) { public RawRefreshToken { + validateValue(value); + } + + private void validateValue(String value) { if (value == null || value.isBlank()) { throw new IllegalArgumentException("Refresh Token 원문은 비어 있을 수 없습니다."); } diff --git a/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java index 3a4030a..7068641 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java +++ b/backend/src/main/java/withoutc/chongchong/auth/token/RefreshTokenHasher.java @@ -12,9 +12,7 @@ public final class RefreshTokenHasher { private static final String HASH_ALGORITHM = "SHA-256"; public HashedRefreshToken hash(RawRefreshToken rawRefreshToken) { - if (rawRefreshToken == null) { - throw new IllegalArgumentException("Refresh Token 원문은 필수입니다."); - } + validateRawRefreshToken(rawRefreshToken); byte[] hashBytes = messageDigest().digest(rawRefreshToken.value().getBytes(StandardCharsets.UTF_8)); String hashValue = HexFormat.of().formatHex(hashBytes); @@ -22,6 +20,12 @@ public HashedRefreshToken hash(RawRefreshToken rawRefreshToken) { return new HashedRefreshToken(hashValue); } + private void validateRawRefreshToken(RawRefreshToken rawRefreshToken) { + if (rawRefreshToken == null) { + throw new IllegalArgumentException("Refresh Token 원문은 필수입니다."); + } + } + private MessageDigest messageDigest() { try { return MessageDigest.getInstance(HASH_ALGORITHM); From dd8ff220d6ecb4d760719a9f737a129d39e11df4 Mon Sep 17 00:00:00 2001 From: JYL35 <154980427+JYL35@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:21:10 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../withoutc/chongchong/auth/exception/AuthErrorCode.java | 2 ++ .../withoutc/chongchong/auth/service/AuthTokenService.java | 2 +- .../chongchong/auth/service/AuthTokenServiceTest.java | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java index 7d0373d..b505c31 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java +++ b/backend/src/main/java/withoutc/chongchong/auth/exception/AuthErrorCode.java @@ -13,6 +13,8 @@ public enum AuthErrorCode implements ErrorCode { ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "요청한 작업을 수행할 권한이 없습니다."), + INVALID_USER_ID(HttpStatus.BAD_REQUEST, "INVALID_USER_ID", "유효하지 않은 사용자 ID입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다."); private final HttpStatus httpStatus; diff --git a/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java index f760686..9f8bca5 100644 --- a/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java +++ b/backend/src/main/java/withoutc/chongchong/auth/service/AuthTokenService.java @@ -57,7 +57,7 @@ public IssuedTokenPair issue(Long userId) { private void validateUserId(Long userId) { if (userId == null || userId <= 0) { - throw new IllegalArgumentException("사용자 ID는 양수여야 합니다."); + throw new AuthException(AuthErrorCode.INVALID_USER_ID); } } } diff --git a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java index 5f14f0b..7fc26ba 100644 --- a/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java +++ b/backend/src/test/java/withoutc/chongchong/auth/service/AuthTokenServiceTest.java @@ -150,8 +150,9 @@ void rejectMissingUser() { @DisplayName("양수가 아닌 사용자 ID로 Token을 발급하지 않는다") void rejectInvalidUserId(Long userId) { assertThatThrownBy(() -> authTokenService.issue(userId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("사용자 ID는 양수여야 합니다."); + .isInstanceOf(AuthException.class) + .extracting(exception -> ((AuthException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.INVALID_USER_ID); assertThat(authSessionRepository.count()).isZero(); }