diff --git a/build.gradle b/build.gradle index 08df474a..7dcabe1c 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,13 @@ dependencies { // jjwt implementation 'io.jsonwebtoken:jjwt:0.12.5' + // paseto + implementation 'dev.paseto:jpaseto-api:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-impl:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-jackson:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-bouncy-castle:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-sodium:0.7.0' + // lombok implementation 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok' diff --git a/docs/pr/PR-139-refactor---auth.md b/docs/pr/PR-139-refactor---auth.md deleted file mode 100644 index 1a45c560..00000000 --- a/docs/pr/PR-139-refactor---auth.md +++ /dev/null @@ -1,96 +0,0 @@ -# Auth API 리팩터링 및 인증 전략 고도화 (PR [#139](https://github.com/juulabel/juulabel-back/pull/141)) - -## 📌 Summary - -이 PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다: - -- 인증 API 도메인의 **명확한 경계 설정** -- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보 -- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화 -- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계 - ---- - -## 1. 구조 리팩터링: 인증 도메인 책임 분리 - -기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다: - -| 기존 경로 | 신규 경로 | 목적 | -| ------------------------- | ---------------------- | ---------------------------- | -| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | -| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | | -| `/v1/api/members/me` | `/v1/api/auth/me` | | -| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 | -| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) | - -💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다. - ---- - -## 2. Refresh Token 기반 인증 및 Rotation 전략 - -### Why Rotation? - -토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다. - -### 동작 방식 - -- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답 -- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록 -- 동일 토큰 재사용 시 → 인증 실패 (401) - -💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화 - ---- - -## 3. 비정상 로그인 탐지 기반 확장 고려 - -### 수집 항목 - -- `Device-Id` (필수 헤더) -- User-Agent, IP (서버 로그 자동 수집) - -이 정보는 향후 다음 기능에 활용됩니다: - -- 동일 계정 다중 위치/디바이스 로그인 탐지 -- 의심 활동에 대한 보안 알림 트리거 -- 로그인 히스토리 시각화 - -💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다. - ---- - -## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환 - -기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다. -새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행: - -- Redis에 등록된 Refresh Token을 블랙리스트화 -- 이후 해당 토큰 사용 시 인증 실패 - -💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화 - ---- - -## 5. Redis 기반 토큰 관리 및 인프라 구성 - -| 항목 | 내용 | -| ----------- | ---------------------------------------------------- | -| 저장소 구성 | AWS ElastiCache (Valkey) | -| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 | -| 라이브러리 | `spring-data-redis (lettuce)` | -| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 | - -💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능 - ---- - -## 6. 적용 시 유의사항 - -| 항목 | 설명 | -| -------------------------------------------- | --------------------------------------------------------- | -| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 | -| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 | -| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 | - ---- diff --git a/docs/pr/PR-141-refactor---auth.md b/docs/pr/PR-141-refactor---auth.md index efb15a06..56ba5b90 100644 --- a/docs/pr/PR-141-refactor---auth.md +++ b/docs/pr/PR-141-refactor---auth.md @@ -1,129 +1,96 @@ -# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#141](https://github.com/juulabel/juulabel-back/pull/143)) +# Auth API 리팩터링 및 인증 전략 고도화 (PR [#141](https://github.com/juulabel/juulabel-back/pull/141)) -## TL;DR +## 📌 Summary -이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다. +이 PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다: -| 항목 | Before | After | 결과 | -| :------------------- | :------------------------------ | :------------ | :--------------------- | -| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | -| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | -| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | -| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | +- 인증 API 도메인의 **명확한 경계 설정** +- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보 +- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화 +- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계 --- -## 💥 핵심 문제 해결 - -### 1. 소셜 로그인 우회 취약점 차단 - -**문제점:** 이전에는 사용자가 소셜 인증 핸드셰이크 과정을 완전히 우회할 수 있었습니다. `/v1/api/auth/sign-up` 엔드포인트에 조작된 데이터를 직접 호출함으로써, 실제 검증 없이 계정을 생성할 수 있었습니다. - -```javascript -// 이전 취약점: 검증되지 않은 회원가입 허용 -POST /v1/api/auth/sign-up -{ - "email": "compromised@email.com", - "nickname": "fake_name", - "provider": "GOOGLE", - "providerId": "fake_google_id", - ... -} -``` - -**해결**: TTL 기반 소셜 인증 상태 관리 - -- 소셜 로그인 시 로그인 정보와 request header로부터 받아올수있는 metatdata를 Redis에 30분 TTL로 저장 -- 회원가입 시 로그인떄와 저장된 정보와 100% 일치하는지 검증 -- 불일치하거나 TTL 만료 시 가입 차단 - -### 2. 인증 로직 보완 - -**Before**: 기존에는 이미 가입된 이메일의 다른 소셜 로그인을 통한 처리를 회원가입부분에서 검증. - -```java -boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); -``` - -**After**: 로그인 과정에서도 검증하여 에러 반환 - -```java -public void validateLoginMember(Provider provider, String providerId) { - if (this.deletedAt != null) { - throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); - } - // 사용자가 등록한 *동일한* 제공업체를 통해 로그인하는지 확인 - if (!this.provider.equals(provider)) { - throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } - // 중요: *올바른* 제공업체의 고유 ID 확인 - if (!this.providerId.equals(providerId)) { - throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); - } -} -``` - -## 3. 데이터베이스 성능 최적화 - -**Before**: 회원가입 과정에서는 INSERT를 시도하기 전에 닉네임 충돌, 이메일 충돌, 탈퇴 상태를 확인하기 위해 여러 번의 중복된 EXISTS 쿼리가 발생했습니다. 이로 인해 불필요한 데이터베이스 왕복이 발생했습니다. - -```java -// 비효율적: 삽입 전 여러 사전 확인 -boolean nicknameExists = memberRepository.existsByNickname(nickname); // 쿼리 1 -boolean emailExists = memberRepository.existsByEmail(email); // 쿼리 2 -boolean isWithdrawn = withdrawalRepository.existsByEmail(email); // 쿼리 3 -memberRepository.save(member); // 쿼리 4 -``` - -**After**: 데이터베이스의 내장된 제약 조건 위반 처리를 활용합니다. INSERT를 직접 시도하고 DataIntegrityViolationException (예: 고유 키 충돌)과 같은 잠재적인 예외 사례를 우아하게 처리합니다. 이 접근 방식은 일반적인 회원가입 흐름을 단일 INSERT 쿼리로 줄입니다. - -```java -// 효율적: 단일 쿼리, DB 제약 조건 활용 (낙관적) -try { - memberRepository.save(member); // 쿼리 1 -} catch (DataIntegrityViolationException e) { - // 특정 제약 조건 위반 처리 (예: 이메일/닉네임 중복) - handleConstraintViolation(e); -} -``` - -## 기타 사항 - -### 불필요한 DTO 제거 - -```java -// 제거된 중간 변환 객체 -public class OAuthLoginInfo { /* 불필요한 래핑 */ } - -// 불필요한 변환 발생 -public record OAuthLoginRequest( - /* 불필요한 래핑 */ -) { - public OAuthLoginInfo toDto() { - Map propertyMap = Map.of( - AuthConstants.CODE, code, - AuthConstants.REDIRECT_URI, redirectUri - ); - return new OAuthLoginInfo(provider, propertyMap); - } -} - -// 객체 변환 간소화 -final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest); -``` - -### AuthExcpetion 추가 - -기존에는 인증/인가 관련 예외(Authentication, Authorization)를 포함해 모든 예외를 동일한 수준에서 처리하고 있었습니다. 이 방식은 간단하지만 다음과 같은 단점이 있습니다: - -❗ 문제점 -• 문제 추적이 어렵다: 인증 관련 문제인지, 비즈니스 로직 문제인지 구분되지 않음 -• 모니터링/알림 설정이 어려움: 특정 보안 이슈에 대한 빠른 탐지가 불가능 -• 책임 경계 불분명: 도메인 계층과 인증 계층의 에러가 동일하게 처리됨 - -⸻ - -🎯 개선 방향: AuthException 정의 및 분리 -• 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의 -• BaseException으로부터 상속, ErrorCode를 명확히 지정 -• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능 +## 1. 구조 리팩터링: 인증 도메인 책임 분리 + +기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다: + +| 기존 경로 | 신규 경로 | 목적 | +| ------------------------- | ---------------------- | ---------------------------- | +| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | +| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | | +| `/v1/api/members/me` | `/v1/api/auth/me` | | +| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 | +| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) | + +💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다. + +--- + +## 2. Refresh Token 기반 인증 및 Rotation 전략 + +### Why Rotation? + +토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다. + +### 동작 방식 + +- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답 +- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록 +- 동일 토큰 재사용 시 → 인증 실패 (401) + +💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화 + +--- + +## 3. 비정상 로그인 탐지 기반 확장 고려 + +### 수집 항목 + +- `Device-Id` (필수 헤더) +- User-Agent, IP (서버 로그 자동 수집) + +이 정보는 향후 다음 기능에 활용됩니다: + +- 동일 계정 다중 위치/디바이스 로그인 탐지 +- 의심 활동에 대한 보안 알림 트리거 +- 로그인 히스토리 시각화 + +💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다. + +--- + +## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환 + +기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다. +새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행: + +- Redis에 등록된 Refresh Token을 블랙리스트화 +- 이후 해당 토큰 사용 시 인증 실패 + +💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화 + +--- + +## 5. Redis 기반 토큰 관리 및 인프라 구성 + +| 항목 | 내용 | +| ----------- | ---------------------------------------------------- | +| 저장소 구성 | AWS ElastiCache (Valkey) | +| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 | +| 라이브러리 | `spring-data-redis (lettuce)` | +| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 | + +💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능 + +--- + +## 6. 적용 시 유의사항 + +| 항목 | 설명 | +| -------------------------------------------- | --------------------------------------------------------- | +| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 | +| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 | +| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 | + +--- diff --git a/docs/pr/PR-140-refactor---public-access-control.md.md b/docs/pr/PR-142-refactor---public-access-control.md.md similarity index 98% rename from docs/pr/PR-140-refactor---public-access-control.md.md rename to docs/pr/PR-142-refactor---public-access-control.md.md index 4e560497..f7a70653 100644 --- a/docs/pr/PR-140-refactor---public-access-control.md.md +++ b/docs/pr/PR-142-refactor---public-access-control.md.md @@ -1,4 +1,4 @@ -# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#140](https://github.com/juulabel/juulabel-back/pull/142)) +# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/142)) ## 개요 diff --git a/docs/pr/PR-143-refactor---auth.md b/docs/pr/PR-143-refactor---auth.md new file mode 100644 index 00000000..8a949cb8 --- /dev/null +++ b/docs/pr/PR-143-refactor---auth.md @@ -0,0 +1,129 @@ +# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#143](https://github.com/juulabel/juulabel-back/pull/143)) + +## TL;DR + +이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다. + +| 항목 | Before | After | 결과 | +| :------------------- | :------------------------------ | :------------ | :--------------------- | +| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | +| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | +| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | +| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | + +--- + +## 💥 핵심 문제 해결 + +### 1. 소셜 로그인 우회 취약점 차단 + +**문제점:** 이전에는 사용자가 소셜 인증 핸드셰이크 과정을 완전히 우회할 수 있었습니다. `/v1/api/auth/sign-up` 엔드포인트에 조작된 데이터를 직접 호출함으로써, 실제 검증 없이 계정을 생성할 수 있었습니다. + +```javascript +// 이전 취약점: 검증되지 않은 회원가입 허용 +POST /v1/api/auth/sign-up +{ + "email": "compromised@email.com", + "nickname": "fake_name", + "provider": "GOOGLE", + "providerId": "fake_google_id", + ... +} +``` + +**해결**: TTL 기반 소셜 인증 상태 관리 + +- 소셜 로그인 시 로그인 정보와 request header로부터 받아올수있는 metatdata를 Redis에 30분 TTL로 저장 +- 회원가입 시 로그인떄와 저장된 정보와 100% 일치하는지 검증 +- 불일치하거나 TTL 만료 시 가입 차단 + +### 2. 인증 로직 보완 + +**Before**: 기존에는 이미 가입된 이메일의 다른 소셜 로그인을 통한 처리를 회원가입부분에서 검증. + +```java +boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); +``` + +**After**: 로그인 과정에서도 검증하여 에러 반환 + +```java +public void validateLoginMember(Provider provider, String providerId) { + if (this.deletedAt != null) { + throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); + } + // 사용자가 등록한 *동일한* 제공업체를 통해 로그인하는지 확인 + if (!this.provider.equals(provider)) { + throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + // 중요: *올바른* 제공업체의 고유 ID 확인 + if (!this.providerId.equals(providerId)) { + throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); + } +} +``` + +## 3. 데이터베이스 성능 최적화 + +**Before**: 회원가입 과정에서는 INSERT를 시도하기 전에 닉네임 충돌, 이메일 충돌, 탈퇴 상태를 확인하기 위해 여러 번의 중복된 EXISTS 쿼리가 발생했습니다. 이로 인해 불필요한 데이터베이스 왕복이 발생했습니다. + +```java +// 비효율적: 삽입 전 여러 사전 확인 +boolean nicknameExists = memberRepository.existsByNickname(nickname); // 쿼리 1 +boolean emailExists = memberRepository.existsByEmail(email); // 쿼리 2 +boolean isWithdrawn = withdrawalRepository.existsByEmail(email); // 쿼리 3 +memberRepository.save(member); // 쿼리 4 +``` + +**After**: 데이터베이스의 내장된 제약 조건 위반 처리를 활용합니다. INSERT를 직접 시도하고 DataIntegrityViolationException (예: 고유 키 충돌)과 같은 잠재적인 예외 사례를 우아하게 처리합니다. 이 접근 방식은 일반적인 회원가입 흐름을 단일 INSERT 쿼리로 줄입니다. + +```java +// 효율적: 단일 쿼리, DB 제약 조건 활용 (낙관적) +try { + memberRepository.save(member); // 쿼리 1 +} catch (DataIntegrityViolationException e) { + // 특정 제약 조건 위반 처리 (예: 이메일/닉네임 중복) + handleConstraintViolation(e); +} +``` + +## 기타 사항 + +### 불필요한 DTO 제거 + +```java +// 제거된 중간 변환 객체 +public class OAuthLoginInfo { /* 불필요한 래핑 */ } + +// 불필요한 변환 발생 +public record OAuthLoginRequest( + /* 불필요한 래핑 */ +) { + public OAuthLoginInfo toDto() { + Map propertyMap = Map.of( + AuthConstants.CODE, code, + AuthConstants.REDIRECT_URI, redirectUri + ); + return new OAuthLoginInfo(provider, propertyMap); + } +} + +// 객체 변환 간소화 +final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest); +``` + +### AuthExcpetion 추가 + +기존에는 인증/인가 관련 예외(Authentication, Authorization)를 포함해 모든 예외를 동일한 수준에서 처리하고 있었습니다. 이 방식은 간단하지만 다음과 같은 단점이 있습니다: + +❗ 문제점 +• 문제 추적이 어렵다: 인증 관련 문제인지, 비즈니스 로직 문제인지 구분되지 않음 +• 모니터링/알림 설정이 어려움: 특정 보안 이슈에 대한 빠른 탐지가 불가능 +• 책임 경계 불분명: 도메인 계층과 인증 계층의 에러가 동일하게 처리됨 + +⸻ + +🎯 개선 방향: AuthException 정의 및 분리 +• 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의 +• BaseException으로부터 상속, ErrorCode를 명확히 지정 +• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능 diff --git a/docs/pr/PR-142-refactor---auth.md b/docs/pr/PR-144-refactor---auth.md similarity index 98% rename from docs/pr/PR-142-refactor---auth.md rename to docs/pr/PR-144-refactor---auth.md index ea5c4808..1b132480 100644 --- a/docs/pr/PR-142-refactor---auth.md +++ b/docs/pr/PR-144-refactor---auth.md @@ -1,4 +1,4 @@ -# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/144)) +# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#144](https://github.com/juulabel/juulabel-back/pull/144)) ## TL;DR diff --git a/docs/pr/PR-145-feat---apple-login.md b/docs/pr/PR-145-feat---apple-login.md new file mode 100644 index 00000000..4457d0ec --- /dev/null +++ b/docs/pr/PR-145-feat---apple-login.md @@ -0,0 +1,351 @@ +# Apple 로그인 구현 및 보안 강화 (PR #145) + +## Executive Summary + +Apple OAuth 2.0 인증 시스템을 구현하면서 기존 소셜 로그인 아키텍처를 보안 중심으로 재설계했습니다. JWT 토큰 기반 시스템에서 Redis 세션 기반 인증으로 전환하고, PASETO 암호화를 도입하여 보안 수준을 대폭 향상시켰습니다. + +### 핵심 보안 개선사항 + +- **Apple ID 토큰 검증**: RSA-2048 공개키 기반 JWT 서명 검증 시스템 +- **세션 기반 인증**: JWT의 보안 취약점을 해결하는 Redis 세션 관리 시스템 +- **PASETO 암호화**: 회원가입 토큰에 대한 ChaCha20-Poly1305 인증 암호화 적용 +- **HttpOnly 쿠키**: XSS 공격 차단을 위한 클라이언트 측 토큰 접근 완전 차단 +- **서버 중심 OAuth 플로우**: 클라이언트 측 토큰 노출 위험 제거 + +## 🔐 보안 아키텍처 개선 + +### 1. Apple OAuth JWT 토큰 검증 + +Apple의 ID 토큰 검증을 위한 RSA 공개키 기반 시스템을 구현했습니다. + +```java +public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { + ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildPublicKey(applePublicKey); + + // Set up JWT parser with the public key + super.key = publicKey; + super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); + + return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class))); +} +``` + +**보안 특징:** +- **동적 키 검증**: JWT Header의 `kid` 값을 통한 Apple 공개키 매칭 +- **RSA-2048 서명 검증**: Apple의 RSA 공개키로 토큰 무결성 검증 +- **토큰 구조 검증**: 3-part JWT 형식 및 필수 클레임 존재 여부 검증 + +### 2. Redis 세션 기반 인증 시스템 + +JWT 토큰의 보안 취약점을 해결하기 위해 Redis 기반 세션 관리 시스템을 도입했습니다. + +```java +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + @Id + private String id; + + @Indexed + private Long memberId; + + private String email; + private MemberRole role; + private String deviceId; + private String ipAddress; + private String userAgent; + private LocalDateTime createdAt; + private LocalDateTime lastAccessedAt; + + @TimeToLive + private Long ttl; // 7 days +} +``` + +**JWT 대비 보안 이점:** + +| 항목 | JWT | Redis 세션 | +|------|-----|------------| +| **토큰 무효화** | 만료까지 불가능 | 즉시 무효화 가능 | +| **권한 변경 반영** | 토큰 재발급 필요 | 실시간 반영 | +| **감사 추적** | 토큰 사용 추적 어려움 | 세션 활동 완전 추적 | +| **보안 사고 대응** | 토큰 블랙리스트 관리 복잡 | 세션 즉시 삭제 | +| **멀티 디바이스 제어** | 토큰별 개별 관리 | 사용자별 통합 관리 | + +**세션 보안 강화:** +```java +private String generateSecureToken() { + byte[] tokenBytes = new byte[TOKEN_LENGTH]; // 32 bytes = 256 bits + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); +} +``` + +### 3. PASETO 기반 회원가입 토큰 + +JWT의 알고리즘 혼동 공격을 방지하기 위해 PASETO v2.local을 도입했습니다. + +```java +public void createToken(OAuthUser oAuthUser, String nonce) { + String token = builder() + .claim(EMAIL_CLAIM, oAuthUser.email()) + .claim(PROVIDER_CLAIM, oAuthUser.provider().name()) + .claim(PROVIDER_ID_CLAIM, oAuthUser.id()) + .claim(NONCE_CLAIM, nonce) + .claim(AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM) + .compact(); + cookieUtil.addCookie(AuthConstants.SIGN_UP_TOKEN_NAME, token, + (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); +} +``` + +**PASETO 보안 우위:** + +| 특성 | JWT | PASETO v2.local | +|------|-----|-----------------| +| **알고리즘 선택** | 개발자 지정 (위험) | ChaCha20-Poly1305 고정 | +| **암호화 방식** | 서명만 가능 | 인증된 암호화 (AEAD) | +| **알고리즘 혼동 공격** | 취약 | 완전 차단 | +| **성능** | RSA 서명 검증 느림 | 대칭키 암호화 빠름 | +| **키 관리** | 공개키/개인키 쌍 | 단일 대칭키 | + +### 4. HttpOnly 쿠키 보안 시스템 + +XSS 공격을 완전히 차단하기 위해 모든 인증 토큰을 HttpOnly 쿠키로 전송합니다. + +```java +private Cookie createSecureCookie(String name, String value, int maxAge) { + boolean isSecure = cookieProperties.isSecure(); + Cookie cookie = new Cookie(name, value); + + // Set domain only for production/secure environments + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); // XSS 차단 + cookie.setSecure(isSecure); // HTTPS 전용 + cookie.setMaxAge(maxAge); + + // Set SameSite attribute based on security requirements + String sameSite = isSecure ? cookieProperties.getSameSiteSecure() : cookieProperties.getSameSiteNonSecure(); + cookie.setAttribute("SameSite", sameSite); // CSRF 차단 + + return cookie; +} +``` + +**쿠키 보안 속성:** +- **HttpOnly**: JavaScript 접근 완전 차단 +- **Secure**: HTTPS 전용 전송 (프로덕션) +- **SameSite**: 크로스사이트 요청 제한 +- **Domain/Path**: 최소 권한 원칙 적용 + +## 🏗️ OAuth 플로우 보안 개선 + +### 서버 중심 OAuth 콜백 처리 + +클라이언트 측 토큰 노출을 방지하기 위해 서버에서 OAuth 플로우를 완전히 제어합니다. + +```java +@Transactional +public void login(Provider provider, String code, String state) { + try { + // Get OAuth user info + OAuthUser oAuthUser = getOAuthUser(provider, code); + + // Process member based on existence and status + Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + if (memberOpt.isPresent()) { + Member member = memberOpt.get(); + if (member.getStatus() == MemberStatus.PENDING) { + handlePendingMember(member, oAuthUser); + } else { + handleExistingMember(member, oAuthUser); + } + } else { + handleNewMember(oAuthUser); + } + + } catch (Exception e) { + Sentry.captureException(e); + httpResponseUtil.redirectToError(); + } +} +``` + +**보안 플로우:** +1. `OAuth Provider` → `서버 콜백 엔드포인트` +2. 서버에서 인가코드 → 액세스 토큰 교환 +3. 사용자 정보 검증 및 세션/토큰 생성 +4. 사용자 상태별 클라이언트 리다이렉트 + +### Factory Pattern 기반 프로바이더 관리 + +```java +@Component +@RequiredArgsConstructor +public class OAuthProviderFactory { + private final KakaoProvider kakaoProvider; + private final GoogleProvider googleProvider; + private final AppleProvider appleProvider; + + private OAuthProvider getOAuthProvider(Provider provider) { + return switch (provider) { + case KAKAO -> kakaoProvider; + case GOOGLE -> googleProvider; + case APPLE -> appleProvider; + default -> throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + }; + } +} +``` + +## 🛡️ 추가 보안 강화 + +### 1. 세션 충돌 방지 + +```java +private String generateUniqueSessionId() { + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + String sessionId = generateSecureToken(); + + if (!userSessionRepository.existsById(sessionId)) { + return sessionId; + } + + log.warn("Session ID collision detected, retrying... Attempt: {}", attempt + 1); + } + + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); +} +``` + +### 2. 토큰 마스킹 로깅 + +```java +private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); +} +``` + +### 3. PASETO 토큰 검증 강화 + +```java +public Member verifyToken(String token) { + Claims claims = parseClaims(token); + + // Extract and validate all claims at once + TokenClaims tokenClaims = extractTokenClaims(claims); + + // Validate audience first (fast check) + if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } + + // Get member and validate + Member member = memberReader.getByEmail(tokenClaims.email()); + validateMemberAgainstToken(member, tokenClaims); + + return member; +} +``` + +## 📊 성능 최적화 + +### 1. Redis 인덱싱 최적화 + +```java +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + @Id + private String id; + + @Indexed // 사용자별 세션 조회 최적화 + private Long memberId; + + // ... other fields +} +``` + +### 2. 암호화 성능 최적화 + +- **ChaCha20-Poly1305**: RSA 대비 약 10배 빠른 암호화/복호화 +- **대칭키 사용**: 32바이트 대칭키로 메모리 사용량 최소화 +- **ObjectMapper 재사용**: 싱글톤 인스턴스로 성능 향상 + +### 3. 세션 활동 업데이트 최적화 + +```java +private void updateSessionActivity(UserSession session) { + try { + session.updateLastAccessed(); + userSessionRepository.save(session); + } catch (Exception e) { + log.warn("Failed to update session activity for session: {}", session.getId(), e); + // Non-critical operation, don't throw exception + } +} +``` + + +## 📋 보안 검증 포인트 + +### 1. Apple JWT 토큰 검증 + +- [x] JWT Header의 `kid` 값 검증 +- [x] Apple 공개키 매칭 및 RSA 서명 검증 +- [x] 토큰 구조 및 필수 클레임 검증 +- [x] 예외 처리 및 에러 로깅 + +### 2. 세션 보안 + +- [x] 256비트 암호학적 안전한 세션 ID 생성 +- [x] 세션 충돌 방지 메커니즘 +- [x] 사용자별 세션 관리 및 일괄 무효화 +- [x] 세션 활동 추적 및 TTL 관리 + +### 3. PASETO 토큰 보안 + +- [x] ChaCha20-Poly1305 인증 암호화 +- [x] Audience 클레임 검증 +- [x] 사용자 상태 및 프로바이더 매칭 검증 +- [x] 15분 단기 만료시간 적용 + +### 4. 쿠키 보안 + +- [x] HttpOnly 플래그로 XSS 차단 +- [x] Secure 플래그로 HTTPS 전용 전송 +- [x] SameSite 속성으로 CSRF 방지 +- [x] 최소 권한 Domain/Path 설정 + +## 🎯 보안 테스트 권장사항 + +### 단위 테스트 +- Apple JWT 토큰 검증 로직 (정상/비정상 케이스) +- 세션 생성/검증/무효화 시나리오 +- PASETO 토큰 생성/검증/만료 처리 +- 쿠키 보안 속성 설정 검증 + +### 통합 테스트 +- OAuth Provider별 End-to-End 플로우 +- 세션 기반 인증 전체 플로우 +- 사용자 상태별 리다이렉트 시나리오 +- 보안 헤더 및 쿠키 속성 검증 + +### 보안 테스트 +- JWT 토큰 위변조 시도 +- 세션 하이재킹 시도 +- XSS/CSRF 공격 시도 +- 토큰 리플레이 공격 시도 + +--- + +*본 구현은 OWASP 보안 가이드라인 및 현대 웹 보안 표준을 준수하여 설계되었습니다.* diff --git a/src/main/java/com/juu/juulabel/JuulabelApplication.java b/src/main/java/com/juu/juulabel/JuulabelApplication.java index 49c3e8d7..6c19a6b4 100644 --- a/src/main/java/com/juu/juulabel/JuulabelApplication.java +++ b/src/main/java/com/juu/juulabel/JuulabelApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; @SpringBootApplication @EnableRedisRepositories +@EnableConfigurationProperties public class JuulabelApplication { public static void main(String[] args) { diff --git a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java deleted file mode 100644 index a7754f21..00000000 --- a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.juu.juulabel.admin; - -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.service.MemberService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; - -@Tag(name = "테스트 API", description = "테스트 API") -@RestController -@RequiredArgsConstructor -public class TestAccessTokenController { - - private final AccessTokenProvider accessTokenProvider; - private final MemberService memberService; - - @Operation(summary = "JWT 테스트용 토큰 발급 API", description = "기본 rldh11111@naver.com 이메일로 JWT 발급") - @GetMapping("/token") - public String testAccessToken(@RequestParam(defaultValue = "rldh11111@naver.com") String email) { - Member member = memberService.getMemberByEmail(email); - return accessTokenProvider.createToken(member); - } - -} diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java index 0aa8468e..c72cb91b 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java @@ -8,25 +8,19 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; -import com.juu.juulabel.auth.domain.SignUpToken; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; import com.juu.juulabel.common.response.CommonResponse; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; -import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import io.swagger.v3.oas.annotations.headers.Header; @@ -42,11 +36,11 @@ public interface AuthApiDocs { }) @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") @ApiResponse(responseCode = "401", description = "인증 실패") - @PostMapping("/login/{provider}") - public ResponseEntity> login( + @GetMapping("/oauth/callback/{provider}") + public ResponseEntity> login( @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, - CsrfToken csrfToken, - @Valid @RequestBody OAuthLoginRequest request); + @RequestParam(required = true) String code, + @RequestParam(required = true) String state); @Operation(summary = "회원가입", description = "새로운 회원 등록 및 초기 토큰 발급") @ApiResponse(responseCode = "200", description = "회원가입 성공", headers = { @@ -54,32 +48,17 @@ public ResponseEntity> login( }) @ApiResponse(responseCode = "400", description = "유효성 검사 실패, 중복된 이메일 또는 닉네임") @PostMapping("/sign-up") - public ResponseEntity> signUp( - @AuthenticationPrincipal SignUpToken signUpToken, + public ResponseEntity> signUp( + @AuthenticationPrincipal Member member, @Valid @RequestBody SignUpMemberRequest request); - @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 토큰 로테이션") - @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")), - }) - @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) - }) - @ApiResponse(responseCode = "403", description = "토큰 재사용 감지", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) - }) - @PostMapping("/refresh") - public ResponseEntity> refresh( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken); - @Operation(summary = "로그아웃", description = "현재 디바이스의 리프레시 토큰 무효화") @ApiResponse(responseCode = "200", description = "로그아웃 성공", headers = { @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) }) @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") @PostMapping("/logout") - public ResponseEntity> logout( - @AuthenticationPrincipal Member member); + public ResponseEntity> logout(); @Operation(summary = "회원 탈퇴", description = "회원 계정 삭제 및 모든 토큰 무효화") @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", headers = { diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java index 4765cd70..561bf065 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java @@ -1,14 +1,8 @@ package com.juu.juulabel.auth.controller; -import com.juu.juulabel.auth.domain.SignUpToken; import com.juu.juulabel.auth.service.AuthService; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; import com.juu.juulabel.common.exception.code.SuccessCode; import com.juu.juulabel.common.response.CommonResponse; import com.juu.juulabel.member.domain.Member; @@ -19,7 +13,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.*; @RestController @@ -29,35 +22,30 @@ public class AuthController implements AuthApiDocs { private final AuthService authService; @Override - public ResponseEntity> login( + public ResponseEntity> login( @PathVariable Provider provider, - CsrfToken csrfToken, - @Valid @RequestBody OAuthLoginRequest request) { - csrfToken.getToken(); + @RequestParam(required = true) String code, + @RequestParam(required = true) String state) { + + authService.login(provider, code, state); - return CommonResponse.success(SuccessCode.SUCCESS, authService.login(request)); + return CommonResponse.success(SuccessCode.SUCCESS); } @Override - public ResponseEntity> signUp( - @AuthenticationPrincipal SignUpToken signUpToken, + public ResponseEntity> signUp( + @AuthenticationPrincipal Member member, @Valid @RequestBody SignUpMemberRequest request) { - return CommonResponse.success(SuccessCode.SUCCESS, authService.signUp(signUpToken, request)); - } - - @Override - public ResponseEntity> refresh( - @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken) { + authService.signUp(member, request); - return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshToken)); + return CommonResponse.success(SuccessCode.SUCCESS); } @Override - public ResponseEntity> logout( - @AuthenticationPrincipal Member member) { + public ResponseEntity> logout() { - authService.logout(member.getId()); + authService.logout(); return CommonResponse.success(SuccessCode.SUCCESS); } diff --git a/src/main/java/com/juu/juulabel/auth/domain/ClientId.java b/src/main/java/com/juu/juulabel/auth/domain/ClientId.java deleted file mode 100644 index 0a7b638d..00000000 --- a/src/main/java/com/juu/juulabel/auth/domain/ClientId.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.juu.juulabel.auth.domain; - -import java.util.Arrays; -import java.util.Optional; - -public enum ClientId { - WEB("web-client"), - IOS("ios-app"), - ANDROID("android-app"), - ADMIN("admin-panel"); - - private final String value; - - ClientId(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - public static Optional from(String value) { - return Arrays.stream(values()) - .filter(c -> c.value.equals(value)) - .findFirst(); - } -} diff --git a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java deleted file mode 100644 index 55f423cd..00000000 --- a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.auth.domain; - -import lombok.*; - -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HASH_PREFIX; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_INDEX_PREFIX; - -import java.io.Serializable; - -import java.util.List; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class RefreshToken implements Serializable { - - private String token; - - private String hashedToken; - - private Long memberId; - - private String deviceId; - - private ClientId clientId; - - private String ipAddress; - - private String userAgent; - - private Long ttl; - - private boolean revoked; - - @Builder - public RefreshToken(String token, String hashedToken, Long memberId, ClientId clientId, String deviceId, - String ipAddress, String userAgent) { - this.token = token; - this.hashedToken = hashedToken; - this.memberId = memberId; - this.clientId = clientId; - this.deviceId = deviceId; - this.ipAddress = ipAddress; - this.userAgent = userAgent; - this.ttl = REFRESH_TOKEN_DURATION.getSeconds(); - this.revoked = false; - } - - public String getTokenKey() { - return REFRESH_TOKEN_HASH_PREFIX + ":" + hashedToken; - } - - public String getIndexKey() { - return REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId; - } - - public List getArgs() { - return List.of(memberId.toString(), clientId.toString(), deviceId, ipAddress, userAgent, ttl.toString()); - } - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java deleted file mode 100644 index 932ac67c..00000000 --- a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.juu.juulabel.auth.domain; - -import java.io.Serializable; -import java.time.Instant; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; -import org.springframework.data.redis.core.TimeToLive; - -import static com.juu.juulabel.common.constants.AuthConstants.SOCIAL_LINK_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.SOCIAL_LINK_PREFIX; - -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.member.domain.Provider; - -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; -import lombok.AccessLevel; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@RedisHash(SOCIAL_LINK_PREFIX) -public class SocialLink implements Serializable { - - @Id - private String hashedEmail; - - private Provider provider; - - private String providerId; - - private String deviceId; - - private String ipAddress; - - private String userAgent; - - private Long usedAt; - - private String nonce; - - @TimeToLive(unit = TimeUnit.SECONDS) - private Long ttl; - - @Builder - public SocialLink(String hashedEmail, Provider provider, String providerId, String deviceId, String userAgent, - String ipAddress, String nonce) { - this.hashedEmail = hashedEmail; - this.provider = provider; - this.providerId = providerId; - this.deviceId = deviceId; - this.userAgent = userAgent; - this.ipAddress = ipAddress; - this.usedAt = null; - this.nonce = nonce; - this.ttl = SOCIAL_LINK_DURATION.getSeconds(); - } - - /** - * Validates the social link against provided parameters for security purposes. - * Throws AuthException if validation fails. - */ - public void validate(SignUpToken signUpToken) { - // Check if already used - if (isAlreadyUsed()) { - throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); - } - - // Validate parameters match stored values - // DISABLE IN DEVELOPMENT ENVIRONMENT - if (!isValidationParametersMatch(signUpToken)) { - throw new AuthException("Validation failed due to parameter mismatch"); - } - } - - /** - * Marks this social link as used with current timestamp. - * Can only be used once. - */ - public void markAsUsed() { - if (isAlreadyUsed()) { - throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); - } - this.usedAt = Instant.now().getEpochSecond(); - } - - /** - * Checks if this social link has already been used. - */ - public boolean isAlreadyUsed() { - return this.usedAt != null; - } - - /** - * Checks if validation parameters match stored values. - * Uses efficient short-circuit evaluation. - */ - private boolean isValidationParametersMatch(SignUpToken signUpToken) { - return Objects.equals(this.provider, signUpToken.provider()) && - Objects.equals(this.providerId, signUpToken.providerId()) && - Objects.equals(this.deviceId, HttpRequestUtil.getDeviceId()) && - Objects.equals(this.userAgent, HttpRequestUtil.getUserAgent()) && - Objects.equals(this.nonce, signUpToken.nonce()); - } -} diff --git a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java deleted file mode 100644 index 4ea283c4..00000000 --- a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class LoginRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public LoginRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/login_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - - List keys = List.of(newTokenKey, indexKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - -} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java deleted file mode 100644 index 77480de7..00000000 --- a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class RevokeRefreshTokenByIndexKeyExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public RevokeRefreshTokenByIndexKeyExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/revoke_refresh_token_by_index_key.lua").getFile().toPath(), - StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(String indexKey, Object... args) { - try { - return redisTemplate.execute(redisScript, List.of(indexKey)); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - -} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java deleted file mode 100644 index 6993058f..00000000 --- a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class RotateRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public RotateRefreshTokenScriptExecutor(RedisTemplate redisTemplate) - throws IOException { - this.redisTemplate = redisTemplate; - - String scriptText = Files.readString( - new ClassPathResource("scripts/rotate_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - String oldTokenKey = args[0].toString(); - - List keys = Arrays.asList(newTokenKey, indexKey, oldTokenKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - - @Override - public void handleRedisScriptError(String errorMessage) { - if (errorMessage.contains("OLD_TOKEN_NOT_FOUND")) { - throw new AuthException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); - } else if (errorMessage.contains("OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED")) { - throw new AuthException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED); - } else if (errorMessage.contains("DEVICE_ID_MISMATCH")) { - throw new AuthException(ErrorCode.DEVICE_ID_MISMATCH); - } else { - throw new AuthException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java deleted file mode 100644 index 46fccad2..00000000 --- a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class SaveRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public SaveRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/save_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - - List keys = Arrays.asList(newTokenKey, indexKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } -} diff --git a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index 4dbe50e4..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.juu.juulabel.auth.repository; - -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; - -public interface RefreshTokenRepository { - - /** - * Saves a refresh token - */ - void save(RefreshToken refreshToken); - - /** - * Rotate - */ - void rotate(RefreshToken refreshToken, String hashedOldToken); - - /** - * Login - */ - void login(RefreshToken refreshToken); - - /** - * Revokes all refresh tokens for a member and device - */ - void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId); - - /** - * Revokes all refresh tokens for a member - */ - void revokeAllByMember(Long memberId); - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java deleted file mode 100644 index 9da8ac87..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.juu.juulabel.auth.repository; - -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.redis.RedisScriptName; -import com.juu.juulabel.redis.ScriptRegistry; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { - - private final ScriptRegistry scriptRegistry; - - @Override - public void save(RefreshToken refreshToken) { - scriptRegistry.get(RedisScriptName.SAVE_REFRESH_TOKEN).execute(refreshToken); - } - - @Override - public void rotate(RefreshToken refreshToken, String hashedOldToken) { - - String oldTokenKey = AuthConstants.REFRESH_TOKEN_HASH_PREFIX + ":" + hashedOldToken; - - scriptRegistry.get(RedisScriptName.ROTATE_REFRESH_TOKEN).execute(refreshToken, oldTokenKey); - } - - @Override - public void login(RefreshToken refreshToken) { - scriptRegistry.get(RedisScriptName.LOGIN_REFRESH_TOKEN).execute(refreshToken); - } - - @Override - public void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId) { - String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId - + ":*"; - - scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); - } - - @Override - public void revokeAllByMember(Long memberId) { - String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":*"; - - scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); - } - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java b/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java deleted file mode 100644 index 7f51aab3..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.juu.juulabel.auth.repository; - -import org.springframework.data.repository.CrudRepository; - -import com.juu.juulabel.auth.domain.SocialLink; - -public interface SocialLinkRepository extends CrudRepository { - -} diff --git a/src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java b/src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java new file mode 100644 index 00000000..6de20565 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java @@ -0,0 +1,10 @@ +package com.juu.juulabel.auth.repository; + +import com.juu.juulabel.member.token.UserSession; + +import org.springframework.data.repository.CrudRepository; + +public interface UserSessionRepository extends CrudRepository { + + void deleteAllByMemberId(Long memberId); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java b/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java new file mode 100644 index 00000000..f3fe62e4 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java @@ -0,0 +1,70 @@ +package com.juu.juulabel.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.WithdrawalRecord; +import com.juu.juulabel.member.repository.WithdrawalRecordWriter; +import com.juu.juulabel.redis.UserSessionManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to account lifecycle operations. + * Handles logout, account deletion, and session cleanup. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountLifecycleService { + + private final UserSessionManager sessionManager; + private final WithdrawalRecordWriter withdrawalRecordWriter; + + /** + * Logs out current user by invalidating their session + */ + public void logout() { + try { + sessionManager.invalidateSession(); + log.debug("User logout successful"); + } catch (Exception e) { + log.warn("Error during logout: {}", e.getMessage()); + // Don't throw exception for logout failures + } + } + + /** + * Permanently deletes member account and creates audit record + * @param member Authenticated member requesting deletion + * @param request Withdrawal request with reason + */ + @Transactional + public void deleteAccount(Member member, WithdrawalRequest request) { + // Validate member can be deleted + if (member.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException("Member already withdrawn", ErrorCode.MEMBER_WITHDRAWN); + } + + // Mark member as deleted (soft delete) + member.deleteAccount(); + + // Create audit record + WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( + request.withdrawalReason(), + member.getEmail(), + member.getNickname()); + withdrawalRecordWriter.store(withdrawalRecord); + + // Revoke all sessions + sessionManager.invalidateAllUserSessions(member.getId()); + + log.debug("Account deletion completed for member: {}", member.getEmail()); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java b/src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java new file mode 100644 index 00000000..15a7c8dc --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java @@ -0,0 +1,201 @@ +package com.juu.juulabel.auth.service; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.function.Function; + +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.request.AppleUser; +import com.juu.juulabel.member.token.OAuthToken; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; + +/** + * Service for handling Apple JWT token operations with improved architecture. + * Separates token parsing from business logic and provides better error handling. + */ +@Service +public class AppleTokenService { + + // Constants for better maintainability + private static final String RSA_ALGORITHM = "RSA"; + private static final String KID_HEADER_FIELD = "kid"; + private static final String SUB_CLAIM = "sub"; + private static final String EMAIL_CLAIM = "email"; + private static final String JWT_SEPARATOR = "\\."; + private static final int HEADER_INDEX = 0; + private static final int EXPECTED_JWT_PARTS = 3; + + // Reuse ObjectMapper instance for better performance + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Extracts Apple user information from JWT token using the provided public keys. + * + * @param publicKeys List of Apple's public keys + * @param oauthToken OAuth token containing the ID token + * @return AppleUser with extracted user information + * @throws CustomJwtException if token processing fails + */ + public AppleUser extractAppleUser(List publicKeys, OAuthToken oauthToken) { + ApplePublicKey applePublicKey = findMatchingPublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); + + return extractFromClaims(oauthToken.idToken(), jwtParser, this::mapToAppleUser); + } + + /** + * Validates an Apple JWT token structure and signature. + * + * @param publicKeys List of Apple's public keys + * @param token JWT token string + * @return true if token is valid, false otherwise + */ + public boolean isValidAppleToken(List publicKeys, String token) { + try { + String kid = extractKidFromToken(token); + ApplePublicKey applePublicKey = findPublicKeyByKid(publicKeys, kid); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); + + parseClaims(token, jwtParser); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Extracts claims from Apple JWT token. + * + * @param publicKeys List of Apple's public keys + * @param token JWT token string + * @return parsed Claims + */ + public Claims extractClaims(List publicKeys, String token) { + String kid = extractKidFromToken(token); + ApplePublicKey applePublicKey = findPublicKeyByKid(publicKeys, kid); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); + + return parseClaims(token, jwtParser); + } + + // Private helper methods + + private AppleUser mapToAppleUser(Claims claims) { + return new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class) + ); + } + + private JwtParser createJwtParser(PublicKey publicKey) { + return Jwts.parser().verifyWith(publicKey).build(); + } + + private T extractFromClaims(String token, JwtParser jwtParser, Function claimsResolver) { + return claimsResolver.apply(parseClaims(token, jwtParser)); + } + + private Claims parseClaims(String token, JwtParser jwtParser) { + try { + return jwtParser.parseSignedClaims(token).getPayload(); + } catch (SignatureException | MalformedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); + } catch (ExpiredJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_EXPIRED_EXCEPTION); + } catch (UnsupportedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (IllegalArgumentException ex) { + throw new CustomJwtException(ErrorCode.JWT_ILLEGAL_ARGUMENT_EXCEPTION); + } + } + + private ApplePublicKey findMatchingPublicKey(List publicKeys, OAuthToken oauthToken) { + String kid = extractKidFromToken(oauthToken.idToken()); + return findPublicKeyByKid(publicKeys, kid); + } + + private ApplePublicKey findPublicKeyByKid(List publicKeys, String kid) { + return publicKeys.stream() + .filter(key -> kid.equals(key.kid())) + .findFirst() + .orElseThrow(() -> new CustomJwtException( + String.format("No matching Apple public key found for kid: %s", kid), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION)); + } + + private String extractKidFromToken(String idToken) { + try { + String[] chunks = idToken.split(JWT_SEPARATOR); + if (chunks.length != EXPECTED_JWT_PARTS) { + throw new CustomJwtException("Invalid JWT format: expected 3 parts separated by dots", + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + + byte[] headerBytes = Base64.getUrlDecoder().decode(chunks[HEADER_INDEX]); + String header = new String(headerBytes, StandardCharsets.UTF_8); + JsonNode headerNode = OBJECT_MAPPER.readTree(header); + + JsonNode kidNode = headerNode.get(KID_HEADER_FIELD); + if (kidNode == null || kidNode.isNull()) { + throw new CustomJwtException("Missing 'kid' field in JWT header", + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + + return kidNode.asText(); + } catch (IllegalArgumentException e) { + throw new CustomJwtException("Failed to decode JWT header: invalid Base64 encoding - " + e.getMessage(), + ErrorCode.JWT_MALFORMED_EXCEPTION); + } catch (JsonProcessingException e) { + throw new CustomJwtException("Failed to parse JWT header as JSON - " + e.getMessage(), + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + } + + private PublicKey buildRSAPublicKey(ApplePublicKey applePublicKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + BigInteger modulus = new BigInteger(1, nBytes); + BigInteger exponent = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + return keyFactory.generatePublic(publicKeySpec); + + } catch (IllegalArgumentException e) { + throw new CustomJwtException("Invalid Base64 encoding in Apple public key - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (NoSuchAlgorithmException e) { + throw new CustomJwtException("RSA algorithm not available - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (InvalidKeySpecException e) { + throw new CustomJwtException("Invalid RSA key specification - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/AuthService.java b/src/main/java/com/juu/juulabel/auth/service/AuthService.java index 0e90e973..c5bf8afe 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -1,153 +1,122 @@ package com.juu.juulabel.auth.service; -import com.juu.juulabel.auth.domain.SignUpToken; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; -import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.http.HttpResponseService; import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.WithdrawalRecord; -import com.juu.juulabel.member.repository.MemberReader; -import com.juu.juulabel.member.repository.MemberWriter; -import com.juu.juulabel.member.repository.WithdrawalRecordWriter; -import com.juu.juulabel.member.util.MemberUtils; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.redis.UserSessionManager; +import com.juu.juulabel.auth.service.OAuthLoginService.MemberStatusResult; + +import io.sentry.Sentry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import com.juu.juulabel.member.request.OAuthUser; -import java.util.Optional; -import java.util.UUID; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * Service for handling authentication operations including login, signup, - * refresh, logout, and account deletion. - * Provides secure OAuth-based authentication with token management. + * Refactored authentication service using specialized service components. + * Acts as an orchestration layer delegating to focused services. */ @Slf4j @Service @RequiredArgsConstructor public class AuthService { - private final MemberReader memberReader; - private final MemberWriter memberWriter; - private final WithdrawalRecordWriter withdrawalRecordWriter; - private final MemberUtils memberUtils; - private final OAuthProviderFactory providerFactory; - private final TokenService tokenService; - private final SocialLinkService socialLinkService; + private final OAuthLoginService oAuthLoginService; + private final MemberCreationService memberCreationService; + private final AccountLifecycleService accountLifecycleService; + private final SignupTokenService signupTokenService; + private final UserSessionManager sessionManager; + private final HttpResponseService httpResponseService; /** - * Handles OAuth login for both new and existing members. - * For new members, creates a signup token; for existing members, creates an - * access token. - * - * @param request OAuth login request containing provider and authorization code - * @return LoginResponse with access token (existing user) or signup token (new - * user) + * Handles OAuth login flow for both new and existing members. + * + * @param provider OAuth provider (Google, Kakao, Apple) + * @param code Authorization code from OAuth provider + * @param state State parameter from OAuth provider */ @Transactional - public LoginResponse login(OAuthLoginRequest request) { - - final OAuthUser oAuthUser = providerFactory.getOAuthUser(request); - final Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); - - return memberOpt - .map(member -> createExistingMemberResponse(member, oAuthUser)) - .orElseGet(() -> createNewMemberResponse(oAuthUser)); + public void login(Provider provider, String code, String state) { + try { + // Authenticate with OAuth provider + OAuthUser oAuthUser = oAuthLoginService.authenticateWithProvider(provider, code); + + // Determine member status and handle accordingly + MemberStatusResult memberResult = oAuthLoginService.determineMemberStatus(oAuthUser); + + if (memberResult.isNewMember()) { + handleNewMember(memberResult.oAuthUser()); + } else if (memberResult.isPendingMember()) { + handlePendingMember(memberResult.member(), memberResult.oAuthUser()); + } else if (memberResult.isActiveMember()) { + handleExistingMember(memberResult.member()); + } + + } catch (Exception e) { + log.error("Login failed for provider {}: {}", provider, e.getMessage()); + Sentry.captureException(e); + httpResponseService.redirectToError(); + } } /** - * Creates login response for existing members. + * Completes member registration with additional information. + * + * @param member Pre-authenticated member from signup token + * @param signUpRequest Additional member registration details */ - private LoginResponse createExistingMemberResponse(Member member, OAuthUser oAuthUser) { - member.validateLoginMember(oAuthUser); - final String accessToken = tokenService.login(member); - return new LoginResponse(accessToken, null, oAuthUser.email()); + @Transactional + public void signUp(Member member, SignUpMemberRequest signUpRequest) { + Member completedMember = memberCreationService.completeSignup(member, signUpRequest); + sessionManager.createSession(completedMember); + + log.debug("Signup completed successfully for: {}", completedMember.getEmail()); } /** - * Creates login response for new members (signup flow). + * Logs out current user by invalidating their session. */ - private LoginResponse createNewMemberResponse(OAuthUser oAuthUser) { - final String nonce = UUID.randomUUID().toString(); - socialLinkService.save(oAuthUser, nonce); - final String signUpToken = tokenService.createSignUpReadyToken(oAuthUser, nonce); - return new LoginResponse(null, signUpToken, oAuthUser.email()); + public void logout() { + accountLifecycleService.logout(); } /** - * Completes member registration using a validated signup token. - * Creates the member, processes additional data, and generates authentication - * tokens. - * - * @param signUpToken validated signup token containing OAuth user information - * @param signUpRequest member registration details - * @return SignUpMemberResponse with the new member's ID + * Permanently deletes member account and creates audit record. + * + * @param member Authenticated member requesting deletion + * @param request Withdrawal request with reason */ @Transactional - public SignUpMemberResponse signUp(SignUpToken signUpToken, SignUpMemberRequest signUpRequest) { - - final Member member = Member.create(signUpRequest, signUpToken); - memberWriter.store(member); + public void deleteAccount(Member member, WithdrawalRequest request) { + accountLifecycleService.deleteAccount(member, request); + } - // Process additional member data (alcohol types, terms agreements) if provided - memberUtils.processMemberData(member, signUpRequest); + // Private helper methods for different member handling scenarios - // Generate authentication tokens for the new member - String accessToken = tokenService.signUp(member); + private void handleNewMember(OAuthUser oAuthUser) { + String nonce = memberCreationService.createPendingMember(oAuthUser); + signupTokenService.createToken(oAuthUser, nonce); + httpResponseService.redirectToSignup(); - return new SignUpMemberResponse(member.getId(), accessToken); + log.debug("New member flow initiated for: {}", oAuthUser.email()); } - /** - * Refreshes an access token using a valid refresh token. - * - * @param refreshToken the current refresh token - * @return RefreshResponse with the new access token - */ - @Transactional(readOnly = true) - public RefreshResponse refresh(String refreshToken) { - final String accessToken = tokenService.rotate(refreshToken); - return new RefreshResponse(accessToken); - } + private void handlePendingMember(Member member, OAuthUser oAuthUser) { + String nonce = memberCreationService.getExistingNonce(member); + signupTokenService.createToken(oAuthUser, nonce); + httpResponseService.redirectToSignup(); - /** - * Logs out a member by revoking their tokens. - * - * @param memberId the ID of the member to log out - */ - @Transactional - public void logout(Long memberId) { - tokenService.logout(memberId); + log.debug("Pending member flow initiated for: {}", oAuthUser.email()); } - /** - * Permanently deletes a member account and creates a withdrawal record. - * This operation revokes all tokens and marks the member as deleted. - * - * @param loginMember the authenticated member requesting account deletion - * @param request withdrawal request containing the reason - */ - @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request) { - - // Mark member as deleted (soft delete) - loginMember.deleteAccount(); - - // Create audit record for withdrawal - final WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( - request.withdrawalReason(), - loginMember.getEmail(), - loginMember.getNickname()); - withdrawalRecordWriter.store(withdrawalRecord); + private void handleExistingMember(Member member) { + sessionManager.createSession(member); + httpResponseService.redirectToLogin(); - // Revoke all authentication tokens - tokenService.withdraw(loginMember.getId()); + log.debug("Existing member login successful for: {}", member.getEmail()); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java new file mode 100644 index 00000000..becd8f5c --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java @@ -0,0 +1,92 @@ +package com.juu.juulabel.auth.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.repository.MemberWriter; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.util.MemberUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to member creation and signup operations. + * Handles new member creation and signup completion. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberCreationService { + + private final MemberWriter memberWriter; + private final MemberUtils memberUtils; + + /** + * Creates a new pending member from OAuth user data + * + * @param oAuthUser OAuth user information + * @return Generated nonce for the new member + */ + @Transactional + public String createPendingMember(OAuthUser oAuthUser) { + // Generate unique nonce for new member + String nonce = UUID.randomUUID().toString(); + + // Create new pending member + Member newMember = Member.create(oAuthUser, nonce); + memberWriter.store(newMember); + + log.debug("Created new pending member for email: {} with nonce: {}", + oAuthUser.email(), nonce); + + return nonce; + } + + /** + * Completes member signup with additional information + * + * @param member Pre-authenticated member from signup token + * @param signUpRequest Additional member registration details + * @return The completed member + */ + @Transactional + public Member completeSignup(Member member, SignUpMemberRequest signUpRequest) { + // Validate member status + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); + } + + // Complete signup process + member.completeSignUp(signUpRequest); + memberWriter.store(member); + + // Process additional member data + memberUtils.processMemberData(member, signUpRequest); + + log.debug("Completed signup for member: {}", member.getEmail()); + + return member; + } + + /** + * Gets existing nonce for pending member (used for existing pending members) + * + * @param member Existing pending member + * @return The member's existing nonce + */ + public String getExistingNonce(Member member) { + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); + } + + return member.getNickname(); // The nonce is stored in nickname for pending members + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java new file mode 100644 index 00000000..8e24cefb --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java @@ -0,0 +1,119 @@ +package com.juu.juulabel.auth.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.properties.RedirectProperties; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.request.OAuthUser; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to OAuth authentication flow. + * Handles OAuth provider interactions and user data retrieval. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthLoginService { + + private final OAuthProviderFactory providerFactory; + private final RedirectProperties redirectProperties; + private final MemberReader memberReader; + + /** + * Performs OAuth authentication and returns user info + * + * @param provider OAuth provider + * @param code Authorization code + * @return OAuth user information + */ + public OAuthUser authenticateWithProvider(Provider provider, String code) { + try { + String redirectUrl = redirectProperties.getRedirectUrl(provider); + return providerFactory.getOAuthUser(provider, code, redirectUrl); + + } catch (Exception e) { + throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); + } + } + + /** + * Determines the member status for OAuth user + * + * @param oAuthUser OAuth user information + * @return Member status result + */ + public MemberStatusResult determineMemberStatus(OAuthUser oAuthUser) { + Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + if (memberOpt.isEmpty()) { + return new MemberStatusResult(null, null, oAuthUser, true); + } + + Member member = memberOpt.get(); + validateMemberForLogin(member, oAuthUser); + + return new MemberStatusResult(member.getStatus(), member, oAuthUser, false); + } + + /** + * Validates that the member can login with the OAuth user + */ + private void validateMemberForLogin(Member member, OAuthUser oAuthUser) { + if (member.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException(ErrorCode.MEMBER_WITHDRAWN); + } + + if (member.getStatus() == MemberStatus.INACTIVE) { + throw new AuthException(ErrorCode.MEMBER_NOT_ACTIVE); + } + + // Validate OAuth user matches member + validateLoginMember(member, oAuthUser); + } + + public void validateLoginMember(Member member, OAuthUser oAuthUser) { + if (member.getDeletedAt() != null) { + throw new AuthException(ErrorCode.MEMBER_WITHDRAWN); + } + + if (member.getStatus() == MemberStatus.INACTIVE) { + throw new AuthException(ErrorCode.MEMBER_NOT_ACTIVE); + } + + if (!member.getProvider().equals(oAuthUser.provider())) { + throw new AuthException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + if (!member.getProviderId().equals(oAuthUser.id())) { + throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); + } + } + + /** + * Result object containing member status and related data + */ + public record MemberStatusResult( + MemberStatus status, + Member member, + OAuthUser oAuthUser, + boolean isNewMember) { + + public boolean isPendingMember() { + return status == MemberStatus.PENDING; + } + + public boolean isActiveMember() { + return status == MemberStatus.ACTIVE; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java new file mode 100644 index 00000000..48d0fdda --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java @@ -0,0 +1,154 @@ +package com.juu.juulabel.auth.service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.http.CookieService; +import com.juu.juulabel.common.provider.token.paseto.PasetoTokenService; +import com.juu.juulabel.common.provider.token.validator.SignupTokenClaims; +import com.juu.juulabel.common.provider.token.validator.SignupTokenValidator; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.request.OAuthUser; + +import dev.paseto.jpaseto.Claims; + +/** + * Service for handling signup token operations with business logic. + * Extends PasetoTokenService to inherit PASETO-specific token operations. + */ +@Service +public class SignupTokenService extends PasetoTokenService { + + private static final String AUDIENCE_CLAIM = "user-signup-completion"; + private static final String EMAIL_CLAIM = "email"; + private static final String PROVIDER_CLAIM = "provider"; + private static final String PROVIDER_ID_CLAIM = "providerId"; + private static final String NONCE_CLAIM = "nonce"; + private static final String AUDIENCE_CLAIM_KEY = "aud"; + + private final SignupTokenValidator validator; + private final MemberReader memberReader; + private final CookieService cookieService; + + public SignupTokenService( + @Value("${app.paseto.sign-up-key}") String secretKey, + SignupTokenValidator validator, + MemberReader memberReader, + CookieService cookieService) { + + super(secretKey, AuthConstants.SIGN_UP_TOKEN_DURATION); + this.validator = validator; + this.memberReader = memberReader; + this.cookieService = cookieService; + } + + /** + * Creates and sets signup token as HTTP-only cookie + */ + public void createAndSetToken(OAuthUser oAuthUser, String nonce) { + Map claims = Map.of( + EMAIL_CLAIM, oAuthUser.email(), + PROVIDER_CLAIM, oAuthUser.provider().name(), + PROVIDER_ID_CLAIM, oAuthUser.id(), + NONCE_CLAIM, nonce, + AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM); + + String token = createToken(claims); + cookieService.addCookie( + AuthConstants.SIGN_UP_TOKEN_NAME, + token, + (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); + } + + /** + * Verifies token and returns authenticated member + */ + public Authentication getAuthentication(String token) { + Member member = verifyTokenAndGetMember(token); + return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); + } + + /** + * Verifies signup token and returns the associated member + */ + public Member verifyTokenAndGetMember(String token) { + // Parse token claims (inherited from PasetoTokenService) + Claims claims = parseToken(token); + + // Convert to map and then to structured claims + Map claimsMap = convertClaimsToMap(claims); + SignupTokenClaims signupClaims = SignupTokenClaims.from(claimsMap); + + // Validate claims + validator.validate(signupClaims); + + // Return validated member + return memberReader.getByEmail(signupClaims.email()); + } + + /** + * Resolves token from header by removing token prefix + * This method maintains compatibility with the existing TokenProvider interface + */ + public String resolveToken(String header) { + if (!StringUtils.hasText(header)) { + throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); + } + return header.replace(AuthConstants.TOKEN_PREFIX, ""); + } + + /** + * Creates signup token and sets it as cookie + * This method name maintains compatibility with the existing + * SignupTokenProvider + */ + public void createToken(OAuthUser oAuthUser, String nonce) { + createAndSetToken(oAuthUser, nonce); + } + + /** + * Converts PASETO Claims to Map for easier processing + */ + private Map convertClaimsToMap(Claims claims) { + Map claimsMap = new HashMap<>(); + + // Extract standard PASETO claims + if (claims.getSubject() != null) { + claimsMap.put("sub", claims.getSubject()); + } + if (claims.getAudience() != null) { + claimsMap.put("aud", claims.getAudience()); + } + if (claims.getIssuer() != null) { + claimsMap.put("iss", claims.getIssuer()); + } + if (claims.getIssuedAt() != null) { + claimsMap.put("iat", claims.getIssuedAt()); + } + if (claims.getExpiration() != null) { + claimsMap.put("exp", claims.getExpiration()); + } + if (claims.getNotBefore() != null) { + claimsMap.put("nbf", claims.getNotBefore()); + } + if (claims.getTokenId() != null) { + claimsMap.put("jti", claims.getTokenId()); + } + + // Extract custom claims + claims.forEach(claimsMap::put); + + return claimsMap; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java deleted file mode 100644 index 6261321b..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.juu.juulabel.auth.service; - -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; - -import com.juu.juulabel.auth.domain.SignUpToken; -import com.juu.juulabel.auth.domain.SocialLink; -import com.juu.juulabel.auth.repository.SocialLinkRepository; -import com.juu.juulabel.common.util.HashingUtil; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.common.util.IpAddressExtractor; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -@Service -@RequiredArgsConstructor -public class SocialLinkService { - - private final SocialLinkRepository socialLinkRepository; - - public void save(OAuthUser oAuthUser, String nonce) { - SocialLink socialLink = SocialLink.builder() - .hashedEmail(HashingUtil.hashSha256(oAuthUser.email())) - .provider(oAuthUser.provider()) - .providerId(oAuthUser.id()) - .deviceId(HttpRequestUtil.getDeviceId()) - .userAgent(HttpRequestUtil.getUserAgent()) - .ipAddress(IpAddressExtractor.getClientIpAddress()) - .nonce(nonce) - .build(); - socialLinkRepository.save(socialLink); - } - - public void verify(SignUpToken signUpToken) { - String hashedEmail = HashingUtil.hashSha256(signUpToken.email()); - - SocialLink socialLink = socialLinkRepository.findById(hashedEmail) - .orElseThrow(() -> new AuthException(ErrorCode.SOCIAL_LINK_NOT_FOUND)); - - socialLink.validate(signUpToken); - - socialLink.markAsUsed(); - socialLinkRepository.save(socialLink); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/TokenService.java b/src/main/java/com/juu/juulabel/auth/service/TokenService.java deleted file mode 100644 index 2795cb1f..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.juu.juulabel.auth.service; - -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.auth.repository.RefreshTokenRepository; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; -import com.juu.juulabel.common.provider.jwt.RefreshTokenProvider; -import com.juu.juulabel.common.provider.jwt.SignupTokenProvider; -import com.juu.juulabel.common.util.CookieUtil; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.common.util.HashingUtil; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.request.OAuthUser; - -import lombok.RequiredArgsConstructor; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Service for managing authentication tokens including access, refresh, and - * signup tokens. - * Handles token creation, rotation, revocation, and cookie management. - */ -@Service -@RequiredArgsConstructor -public class TokenService { - - private final AccessTokenProvider accessTokenProvider; - private final RefreshTokenProvider refreshTokenProvider; - private final SignupTokenProvider signupTokenProvider; - private final RefreshTokenRepository refreshTokenRepository; - private final CookieUtil cookieUtil; - - /** - * Creates and sets tokens for member registration. - * Clears any existing signup token upon successful registration. - * - * @param member the member to create tokens for - */ - @Transactional - public String signUp(Member member) { - return createAccessAndRefreshToken(member, refreshTokenRepository::save); - } - - /** - * Creates signup ready token for OAuth flow with enhanced validation. - * - * @param oAuthUser the OAuth user information - * @param nonce the security nonce - */ - public String createSignUpReadyToken(OAuthUser oAuthUser, String nonce) { - return signupTokenProvider.createToken(oAuthUser, nonce); - } - - /** - * Creates tokens for login and manages device-specific token rotation. - * - * @param member the member to create tokens for - */ - @Transactional - public String login(Member member) { - return createAccessAndRefreshToken(member, refreshTokenRepository::login); - } - - /** - * Rotates refresh token for enhanced security. - * Implements secure token rotation to prevent token replay attacks. - * - * @param oldToken the current refresh token to rotate - */ - @Transactional - public String rotate(String oldToken) { - - final Member member = refreshTokenProvider.getMemberFromToken(oldToken); - final String hashedOldToken = HashingUtil.hashSha256(oldToken); - final RefreshToken newRefreshToken = refreshTokenProvider.buildRefreshToken(member); - - refreshTokenRepository.rotate(newRefreshToken, hashedOldToken); - cookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, newRefreshToken.getToken(), - (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); - - return accessTokenProvider.createToken(member); - } - - /** - * Revokes refresh token for logout with device-specific cleanup. - * - * @param memberId the member ID for logout - */ - @Transactional - public void logout(Long memberId) { - final String deviceId = HttpRequestUtil.getDeviceId(); - refreshTokenRepository.revokeByMemberAndDevice(memberId, ClientId.WEB, deviceId); - cookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); - } - - /** - * Revokes all refresh tokens for account withdrawal. - * Performs complete token cleanup for account deletion. - * - * @param memberId the member ID for account withdrawal - */ - @Transactional - public void withdraw(Long memberId) { - refreshTokenRepository.revokeAllByMember(memberId); - cookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); - } - - /** - * Common method for creating and setting tokens with different repository - * operations. - * Centralizes token creation logic to reduce code duplication. - * - * @param member the member to create tokens for - * @param repositoryOperation the repository operation to perform - */ - private String createAccessAndRefreshToken(Member member, RepositoryOperation repositoryOperation) { - - final RefreshToken refreshToken = refreshTokenProvider.buildRefreshToken(member); - - repositoryOperation.execute(refreshToken); - - cookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, refreshToken.getToken(), - (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); - return accessTokenProvider.createToken(member); - - } - - /** - * Functional interface for repository operations. - * Enables flexible repository operation handling. - */ - @FunctionalInterface - private interface RepositoryOperation { - void execute(RefreshToken refreshToken); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java new file mode 100644 index 00000000..bcf926fb --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java @@ -0,0 +1,36 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Strategy interface for different authentication types. + * Allows the authorization filter to handle different auth flows cleanly. + */ +public interface AuthenticationStrategy { + + /** + * Checks if this strategy can handle the current request + */ + boolean canHandle(HttpServletRequest request); + + /** + * Creates authentication from the request + * @param request HTTP request containing authentication data + * @return Authentication object or null if not authenticated + */ + Authentication authenticate(HttpServletRequest request); + + /** + * Returns the priority order of this strategy (lower = higher priority) + */ + default int getOrder() { + return 100; + } + + /** + * Returns the name of this authentication strategy for logging + */ + String getStrategyName(); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java new file mode 100644 index 00000000..f3666b09 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java @@ -0,0 +1,86 @@ +package com.juu.juulabel.common.auth; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolves and applies appropriate authentication strategy for incoming + * requests. + * Coordinates multiple authentication strategies in priority order. + */ +@Slf4j +@Component +public class AuthenticationStrategyResolver { + + private final List strategies; + + public AuthenticationStrategyResolver(List strategies) { + this.strategies = strategies.stream() + .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) + .toList(); + } + + /** + * Resolves authentication for the given request using available strategies + * + * @param request HTTP request to authenticate + * @return Authentication object or null if no authentication applies + */ + public Optional resolveAuthentication(HttpServletRequest request) { + // Skip OPTIONS requests + if ("OPTIONS".equals(request.getMethod())) { + log.trace("Skipping authentication for OPTIONS request: {}", request.getRequestURI()); + return Optional.empty(); + } + + // Find the first strategy that can handle this request (sorted by priority) + Optional applicableStrategy = strategies.stream() + .filter(strategy -> strategy.canHandle(request)) + .findFirst(); + + if (applicableStrategy.isEmpty()) { + log.trace("No authentication strategy found for request: {}", request.getRequestURI()); + return Optional.empty(); + } + + AuthenticationStrategy strategy = applicableStrategy.get(); + + try { + log.debug("Using {} strategy for request: {}", + strategy.getStrategyName(), request.getRequestURI()); + + Authentication authentication = strategy.authenticate(request); + + if (authentication != null) { + log.debug("Authentication successful using {} strategy for: {}", + strategy.getStrategyName(), authentication.getName()); + } else { + log.debug("No authentication provided by {} strategy", strategy.getStrategyName()); + } + + return Optional.ofNullable(authentication); + + } catch (Exception e) { + log.warn("Authentication failed using {} strategy for {}: {}", + strategy.getStrategyName(), request.getRequestURI(), e.getMessage()); + throw e; // Re-throw to let filter handle the exception + } + } + + /** + * Returns the list of available authentication strategies for debugging + */ + public List getAvailableStrategies() { + return strategies.stream() + .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) + .map(AuthenticationStrategy::getStrategyName) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java new file mode 100644 index 00000000..0cbafa3d --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java @@ -0,0 +1,68 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.service.SignupTokenService; +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.http.CookieService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Authentication strategy for signup token validation. + * Handles requests to signup endpoints that require signup token. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SignupTokenAuthenticationStrategy implements AuthenticationStrategy { + + private static final String SIGNUP_PATH_PREFIX = "/v1/api/auth/sign-up"; + + private final SignupTokenService signupTokenService; + private final CookieService cookieService; + + @Override + public boolean canHandle(HttpServletRequest request) { + return request.getRequestURI().startsWith(SIGNUP_PATH_PREFIX); + } + + @Override + public Authentication authenticate(HttpServletRequest request) { + String signupToken = cookieService.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME) + .orElse(null); + + if (signupToken == null || signupToken.trim().isEmpty()) { + log.warn("Signup token missing for signup request: {}", request.getRequestURI()); + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } + + try { + String token = signupTokenService.resolveToken(signupToken); + Authentication authentication = signupTokenService.getAuthentication(token); + + log.debug("Signup token authentication successful for: {}", + authentication.getName()); + return authentication; + + } catch (Exception e) { + log.warn("Signup token validation failed: {}", e.getMessage()); + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } + } + + @Override + public int getOrder() { + return 10; // High priority for signup requests + } + + @Override + public String getStrategyName() { + return "SignupToken"; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java new file mode 100644 index 00000000..0df1306f --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java @@ -0,0 +1,75 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.http.CookieService; +import com.juu.juulabel.redis.UserSessionManager; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Authentication strategy for user session validation. + * Handles regular authenticated requests using session cookies. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSessionAuthenticationStrategy implements AuthenticationStrategy { + + private final UserSessionManager sessionManager; + private final CookieService cookieService; + + @Override + public boolean canHandle(HttpServletRequest request) { + // This strategy handles any request with a session token + // (but lower priority than signup token strategy) + return cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME).isPresent(); + } + + @Override + public Authentication authenticate(HttpServletRequest request) { + String authToken = cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME) + .orElse(null); + + if (authToken == null || authToken.trim().isEmpty()) { + log.debug("No auth token found for request: {}", request.getRequestURI()); + return null; // Not an error - just no authentication + } + + try { + Authentication authentication = sessionManager.getAuthentication(authToken); + log.debug("Session authentication successful for: {}", + authentication.getName()); + return authentication; + + } catch (Exception e) { + log.warn("Session authentication failed for token: {} - {}", + maskToken(authToken), e.getMessage()); + return null; // Don't throw exception - let request proceed unauthenticated + } + } + + @Override + public int getOrder() { + return 50; // Lower priority than signup token + } + + @Override + public String getStrategyName() { + return "UserSession"; + } + + /** + * Masks sensitive token for logging + */ + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java b/src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java new file mode 100644 index 00000000..e551d2b1 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java @@ -0,0 +1,28 @@ +package com.juu.juulabel.common.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.token.AppleToken; + +@FeignClient(value = "apple-auth", url = "${api.apple.aauth}") +public interface AppleAuthClient { + + @PostMapping(value = "/auth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + AppleToken generateOAuthToken( + @RequestParam(name = "code") String code, + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "client_secret") String clientSecret, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "grant_type") String grantType); + + @GetMapping("/auth/keys") + List getApplePublicKeys(); +} diff --git a/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java b/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java index 6e201a4e..7886d740 100644 --- a/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java +++ b/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java @@ -11,9 +11,9 @@ public interface GoogleAuthClient { @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) GoogleToken generateOAuthToken(@RequestParam(name = "code") String code, - @RequestParam(name = "client_id") String clientId, - @RequestParam(name = "client_secret") String clientSecret, - @RequestParam(name = "redirect_uri") String redirectUri, - @RequestParam(name = "grant_type") String grantType); + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "client_secret") String clientSecret, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "grant_type") String grantType); } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java b/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java index 8ae66d2b..161f32ee 100644 --- a/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java +++ b/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java @@ -1,6 +1,5 @@ package com.juu.juulabel.common.client; - import com.juu.juulabel.member.request.KakaoUser; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.http.HttpHeaders; diff --git a/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java b/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java index 9bcba7db..55cab86c 100644 --- a/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java +++ b/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java @@ -11,8 +11,8 @@ public interface KakaoAuthClient { @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) KakaoToken generateOAuthToken(@RequestParam(name = "grant_type") String grantType, - @RequestParam(name = "client_id") String clientId, - @RequestParam(name = "redirect_uri") String redirectUri, - @RequestParam(name = "code") String code, - @RequestParam(name = "client_secret") String clientSecret); + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "code") String code, + @RequestParam(name = "client_secret") String clientSecret); } diff --git a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java index ddf8cc27..b525ebe4 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -1,7 +1,8 @@ package com.juu.juulabel.common.config; -import com.juu.juulabel.common.filter.JwtAuthorizationFilter; -import com.juu.juulabel.common.filter.JwtExceptionFilter; +import com.juu.juulabel.common.filter.AuthorizationFilter; +import com.juu.juulabel.common.filter.AuthExceptionFilter; +import com.juu.juulabel.common.handler.CustomAccessDeniedHandler; import com.juu.juulabel.member.domain.MemberRole; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -14,8 +15,6 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -27,13 +26,15 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthorizationFilter jwtAuthorizationFilter; - private final JwtExceptionFilter jwtExceptionFilter; + private final AuthorizationFilter authorizationFilter; + private final AuthExceptionFilter authExceptionFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; - // 완전 공개 엔드 포인트 (우선순위 최상) + // 완전 공개 엔드 포인트 (우선순위 최상) - 가장 자주 호출되는 것을 앞에 private static final String[] PUBLIC_ENDPOINTS = { - "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/auth/refresh", "/v1/api/auth/login/**" + "/error", "/favicon.ico", "/", + "/swagger-ui/**", "/v3/api-docs/**", + "/actuator/**" }; // 관리자 전용 엔드포인트 @@ -41,7 +42,7 @@ public class SecurityConfig { "/v1/api/admins/permission/test" }; - // 인증/인가 필요한 특정 GET 엔드포인트 + // 인증/인가 필요한 특정 GET 엔드포인트 - 성능을 위해 구체적인 패턴을 앞에 private static final String[] PROTECTED_GET_ENDPOINTS = { "/v1/api/members/my-info", "/v1/api/members/my-space", @@ -50,29 +51,23 @@ public class SecurityConfig { "/v1/api/members/alcoholic-drinks/my" }; - // CORS 허용 원본 + // CORS 허용 원본 - 개발 환경을 앞에 배치 private static final String[] ALLOWED_ORIGINS = { - "http://localhost:8084", "http://localhost:8080", + "http://localhost:8084", "http://localhost:5173", "http://localhost:3000", + "https://juulabel.com", "https://api.juulabel.com", "https://dev.juulabel.com", "https://qa.juulabel.com", - "https://juulabel.com", "https://d3jwyw9rpnxu8p.cloudfront.net" }; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http - - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) - .requireCsrfProtectionMatcher(request -> request.getServletPath() - .equals("/v1/api/auth/refresh"))) - + .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(session -> session @@ -85,13 +80,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Configure CORS .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // Configure exception handling + .exceptionHandling(exceptions -> exceptions + .accessDeniedHandler(customAccessDeniedHandler)) + // Configure authorization rules .authorizeHttpRequests(this::configureAuthorization) // Add custom filters - .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) - + .addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(authExceptionFilter, AuthorizationFilter.class) .build(); } diff --git a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java index fb3118a1..751fd4ea 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -9,16 +9,12 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; - public static final String REFRESH_TOKEN_NAME = "REFRESH-TOKEN"; + public static final String AUTH_TOKEN_NAME = "auth_token"; + public static final String SIGN_UP_TOKEN_NAME = "sign_up_token"; - public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(15); - public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(15); + public static final int USER_SESSION_TTL = 60 * 60 * 24 * 7; public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15); - public static final Duration SOCIAL_LINK_DURATION = Duration.ofMinutes(20); - - // Redis Prefixt - public static final String SOCIAL_LINK_PREFIX = "social_link"; - public static final String REFRESH_TOKEN_HASH_PREFIX = "refresh_token"; - public static final String REFRESH_TOKEN_INDEX_PREFIX = "refresh_index"; + // Redis Prefix + public static final String USER_SESSION_PREFIX = "user_session"; } diff --git a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java index a6b53546..146d367c 100644 --- a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java +++ b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java @@ -12,7 +12,7 @@ @Component public class ProviderConverter implements Converter { - private static final Set ALLOWED_PROVIDERS = Set.of(Provider.GOOGLE, Provider.KAKAO); + private static final Set ALLOWED_PROVIDERS = Set.of(Provider.GOOGLE, Provider.KAKAO, Provider.APPLE); @Override public Provider convert(String source) { diff --git a/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java b/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java index 3a4358ed..349813e0 100644 --- a/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java +++ b/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +@Deprecated public record OAuthLoginRequest( @NotBlank(message = "인가코드가 누락되었습니다.") String code, @NotNull(message = "리다이렉트 URI가 누락되었습니다.") String redirectUri, diff --git a/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java deleted file mode 100644 index e60d046e..00000000 --- a/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.juu.juulabel.common.dto.response; - -public record LoginResponse( - String accessToken, - String signUpToken, - String email) { -} diff --git a/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java deleted file mode 100644 index 2a1418f4..00000000 --- a/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.juu.juulabel.common.dto.response; - -public record RefreshResponse(String accessToken) { - -} diff --git a/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java index 20726b93..13319255 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java @@ -6,6 +6,5 @@ @Schema(description = "연관 검색어 리스트 조회 응답") public record RelationSearchResponse( - List relationSearch -) { -} + List relationSearch) { +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java index 6b71e9c4..6bbb7225 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.dto.response; +@Deprecated public record SignUpMemberResponse( Long memberId, String accessToken) { diff --git a/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java b/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java new file mode 100644 index 00000000..c35f1bf8 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java @@ -0,0 +1,27 @@ +package com.juu.juulabel.common.exception; + +import com.juu.juulabel.common.exception.code.ErrorCode; + +import lombok.Getter; + +@Getter +public class CustomPasetoException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomPasetoException(String message) { + super(message); + this.errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + } + + public CustomPasetoException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomPasetoException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index 0b50a130..89bdee92 100644 --- a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java +++ b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java @@ -23,6 +23,13 @@ public enum ErrorCode { INVALID_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "인증이 올바르지 않습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + /** + * CSRF Security + */ + CSRF_TOKEN_INVALID(HttpStatus.FORBIDDEN, "CSRF 토큰이 유효하지 않습니다."), + CSRF_TOKEN_MISSING(HttpStatus.FORBIDDEN, "CSRF 토큰이 누락되었습니다."), + CSRF_TOKEN_MISMATCH(HttpStatus.FORBIDDEN, "CSRF 토큰이 일치하지 않습니다."), + /** * Json Web Token */ @@ -31,6 +38,16 @@ public enum ErrorCode { JWT_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + /** + * Paseto + */ + PAS_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + PAS_UNSUPPORTED_EXCEPTION(HttpStatus.BAD_REQUEST, "지원되지 않는 토큰입니다."), + PAS_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), + PAS_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + PAS_SECURITY_EXCEPTION(HttpStatus.UNAUTHORIZED, "보안 오류가 발생하였습니다."), + PAS_IO_EXCEPTION(HttpStatus.BAD_REQUEST, "IO 오류가 발생하였습니다."), + /** * Authorization * Authorization @@ -38,18 +55,19 @@ public enum ErrorCode { DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "헤더에 Device-Id가 누락되었습니다."), OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 로그인 경로를 찾을 수 없습니다."), + SIGN_UP_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "회원가입 세션이 만료되었습니다."), + /** - * AuthException + * User Session */ - REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), - REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용 감지"), + USER_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "세션이 만료되었습니다."), + USER_SESSION_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "세션 재사용 감지"), - SOCIAL_LINK_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 링크를 찾을 수 없습니다."), - SOCIAL_LINK_ALREADY_USED(HttpStatus.BAD_REQUEST, "소셜 링크가 이미 사용되었습니다."), PROVIDER_ID_MISMATCH(HttpStatus.FORBIDDEN, "소셜 아이디 불일치"), DEVICE_ID_MISMATCH(HttpStatus.FORBIDDEN, "Device-Id 불일치"), USER_AGENT_MISMATCH(HttpStatus.FORBIDDEN, "User-Agent 불일치"), - HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), + HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), + /** * Admin, Member */ @@ -58,6 +76,7 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "회원 정보를 찾을 수 없습니다."), MEMBER_EMAIL_DUPLICATE(HttpStatus.BAD_REQUEST, "중복된 이메일입니다."), MEMBER_NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "중복된 닉네임입니다."), + MEMBER_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "활성화되지 않은 회원입니다."), /** * TERMS diff --git a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java index b6a0b7e3..40a164ec 100644 --- a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java +++ b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java @@ -1,46 +1,44 @@ package com.juu.juulabel.common.factory; +import com.juu.juulabel.common.provider.oauth.AppleProvider; import com.juu.juulabel.common.provider.oauth.GoogleProvider; import com.juu.juulabel.common.provider.oauth.KakaoProvider; import com.juu.juulabel.common.provider.oauth.OAuthProvider; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.token.OAuthToken; + +import lombok.RequiredArgsConstructor; import com.juu.juulabel.member.domain.Provider; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class OAuthProviderFactory { private final KakaoProvider kakaoProvider; private final GoogleProvider googleProvider; - - public OAuthProviderFactory(KakaoProvider kakaoProvider, - GoogleProvider googleProvider) { - this.kakaoProvider = kakaoProvider; - this.googleProvider = googleProvider; - } + private final AppleProvider appleProvider; private OAuthProvider getOAuthProvider(Provider provider) { return switch (provider) { case KAKAO -> kakaoProvider; case GOOGLE -> googleProvider; + case APPLE -> appleProvider; default -> throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); }; } - public OAuthUser getOAuthUser(OAuthLoginRequest request) { - - Provider provider = request.provider(); - String accessToken = getOAuthProvider(provider) - .getOAuthToken(request.redirectUri(), request.code()) - .accessToken(); + public OAuthUser getOAuthUser(Provider provider, String code, String redirectUrl) { + + OAuthToken oauthToken = getOAuthProvider(provider) + .getOAuthToken(redirectUrl, code); return getOAuthProvider(provider) - .getOAuthUser(accessToken); + .getOAuthUser(oauthToken); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java new file mode 100644 index 00000000..a9013e9a --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java @@ -0,0 +1,40 @@ +package com.juu.juulabel.common.filter; + +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.SecurityResponseUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthExceptionFilter extends OncePerRequestFilter { + + private final SecurityResponseUtil securityResponseUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (AuthenticationException ex) { + log.warn("Authentication failed for request {}: {}", request.getRequestURI(), ex.getMessage()); + securityResponseUtil.setErrorResponse(response, HttpStatus.BAD_REQUEST, ex); + } catch (Exception ex) { + log.error("Unexpected exception in auth filter for request {}: {}", + request.getRequestURI(), ex.getMessage()); + securityResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, + ErrorCode.INVALID_AUTHENTICATION, ex.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java new file mode 100644 index 00000000..f21a2f94 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java @@ -0,0 +1,95 @@ +package com.juu.juulabel.common.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.auth.AuthenticationStrategyResolver; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.response.CommonResponse; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Optional; + +/** + * Improved authorization filter using strategy pattern. + * Delegates authentication logic to specialized strategies. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthorizationFilter extends OncePerRequestFilter { + + private static final String UTF_8 = "UTF-8"; + + private final AuthenticationStrategyResolver strategyResolver; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + // Use strategy resolver to determine and apply authentication + Optional authenticationOpt = strategyResolver.resolveAuthentication(request); + + // Set authentication in security context if present + authenticationOpt.ifPresent(authentication -> { + SecurityContextHolder.getContext().setAuthentication(authentication); + log.trace("Authentication set in security context for: {}", authentication.getName()); + }); + + } catch (AuthException e) { + log.warn("Authentication failed for request {}: {}", request.getRequestURI(), e.getMessage()); + handleAuthException(response, e); + return; + } catch (Exception e) { + log.error("Unexpected exception during authentication for {}: {}", + request.getRequestURI(), e.getMessage()); + handleGenericException(response, e); + return; + } + + // Continue with the filter chain + filterChain.doFilter(request, response); + } + + /** + * Handles authentication exceptions with proper error response + */ + private void handleAuthException(HttpServletResponse response, AuthException e) throws IOException { + writeErrorResponse(response, HttpStatus.UNAUTHORIZED, + CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); + } + + /** + * Handles unexpected exceptions with generic error response + */ + private void handleGenericException(HttpServletResponse response, Exception e) throws IOException { + writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, + CommonResponse.fail(com.juu.juulabel.common.exception.code.ErrorCode.INTERNAL_SERVER_ERROR, + "Authentication error").getBody()); + } + + /** + * Writes standardized error response + */ + private void writeErrorResponse(HttpServletResponse response, HttpStatus status, CommonResponse errorResponse) + throws IOException { + response.setCharacterEncoding(UTF_8); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java deleted file mode 100644 index 0c20f9c4..00000000 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.juu.juulabel.common.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; -import com.juu.juulabel.common.provider.jwt.SignupTokenProvider; -import com.juu.juulabel.common.response.CommonResponse; -import com.juu.juulabel.common.util.HttpRequestUtil; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Slf4j -@Component -@RequiredArgsConstructor -public class JwtAuthorizationFilter extends OncePerRequestFilter { - - private final AccessTokenProvider accessTokenProvider; - private final SignupTokenProvider signUpTokenProvider; - private final ObjectMapper objectMapper; - - // Cache frequently used paths for better performance - private static final String SIGNUP_PATH_PREFIX = "/v1/api/auth/sign-up"; - private static final String UTF_8 = "UTF-8"; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - try { - String authHeader = extractAuthorizationHeader(request); - - if (authHeader != null) { - if (isSignUpRequest()) { - processSignUpToken(authHeader); - } else { - processAccessToken(authHeader); - } - } else if (isSignUpRequest()) { - // Sign-up requests require authentication - throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); - } - // For other requests without auth header, let Spring Security handle - // authorization - - } catch (CustomJwtException e) { - handleJwtException(response, e); - return; - } catch (AuthException e) { - handleAuthException(response, e); - return; - } - - filterChain.doFilter(request, response); - } - - /** - * Extract Authorization header directly from request for better performance - */ - private String extractAuthorizationHeader(HttpServletRequest request) { - return request.getHeader(HttpHeaders.AUTHORIZATION); - } - - /** - * Check if the request is a sign-up request using the available request - * parameter - */ - private boolean isSignUpRequest() { - return HttpRequestUtil.isPathMatch(SIGNUP_PATH_PREFIX); - } - - /** - * Process sign-up token with validation - */ - private void processSignUpToken(String authHeader) { - try { - String token = signUpTokenProvider.resolveToken(authHeader); - - Authentication authentication = signUpTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - - } catch (Exception e) { - log.error("Unexpected error in sign-up token processing", e); - throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - /** - * Process access token with validation - */ - private void processAccessToken(String authHeader) { - String token = accessTokenProvider.resolveToken(authHeader); - Authentication authentication = accessTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - /** - * Handle JWT-specific exceptions with appropriate error codes - */ - private void handleJwtException(HttpServletResponse response, CustomJwtException e) throws IOException { - writeErrorResponse(response, HttpStatus.UNAUTHORIZED, - CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); - } - - /** - * Handle authentication exceptions - */ - private void handleAuthException(HttpServletResponse response, AuthException e) throws IOException { - writeErrorResponse(response, HttpStatus.UNAUTHORIZED, - CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); - } - - /** - * Write error response with proper JSON serialization - */ - private void writeErrorResponse(HttpServletResponse response, HttpStatus status, CommonResponse errorResponse) - throws IOException { - response.setCharacterEncoding(UTF_8); - response.setStatus(status.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java deleted file mode 100644 index 1bfc5bd0..00000000 --- a/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.juu.juulabel.common.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.response.CommonResponse; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.core.AuthenticationException; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class JwtExceptionFilter extends OncePerRequestFilter { - - private final ObjectMapper objectMapper; - private static final String UTF_8 = "UTF-8"; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - try { - filterChain.doFilter(request, response); - } catch (AuthenticationException ex) { - setErrorResponse(response, HttpStatus.BAD_REQUEST, - objectMapper.writeValueAsString(CommonResponse.fail(ex))); - } catch (CustomJwtException ex) { - setErrorResponse( - response, - ex.getErrorCode().getHttpStatus(), - objectMapper.writeValueAsString(CommonResponse.fail(ex.getErrorCode(), ex.getMessage()))); - } - } - - private void setErrorResponse(HttpServletResponse response, HttpStatus status, String body) throws IOException { - response.setCharacterEncoding(UTF_8); - response.setStatus(status.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(body); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..bf4904e5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,40 @@ +package com.juu.juulabel.common.handler; + +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.SecurityResponseUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityResponseUtil securityResponseUtil; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + String requestInfo = String.format("%s %s", request.getMethod(), request.getRequestURI()); + // Handle CSRF exceptions with optimized lookup + + handleAccessDenied(response, accessDeniedException.getMessage(), requestInfo); + } + + private void handleAccessDenied(HttpServletResponse response, String message, + String requestInfo) throws IOException { + log.warn("Access denied for {}: {}", requestInfo, message); + securityResponseUtil.setErrorResponse(response, HttpStatus.FORBIDDEN, + ErrorCode.INVALID_AUTHENTICATION, message); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/CookieService.java b/src/main/java/com/juu/juulabel/common/http/CookieService.java new file mode 100644 index 00000000..1cff164d --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/CookieService.java @@ -0,0 +1,176 @@ +package com.juu.juulabel.common.http; + +import java.util.Arrays; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.properties.CookieProperties; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for secure cookie management operations. + * Provides methods for creating, retrieving, and removing HTTP cookies with + * security best practices. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CookieService { + + // Cookie removal configuration + private static final int COOKIE_REMOVAL_MAX_AGE = 0; + private static final String EMPTY_VALUE = ""; + + private final HttpContextService httpContextService; + private final CookieProperties cookieProperties; + + /** + * Retrieves a cookie value by name from the current HTTP request + * @param name the cookie name to search for + * @return the cookie value if found, null otherwise + */ + public Optional getCookie(String name) { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getCookies) + .map(cookies -> findCookieByName(cookies, name)) + .orElse(Optional.empty()); + } + + /** + * Adds a secure HTTP-only cookie to the response with comprehensive security + * settings + * @param name the cookie name + * @param value the cookie value + * @param maxAge the cookie max age in seconds + */ + public void addCookie(String name, String value, int maxAge) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + Cookie cookie = createSecureCookie(name, value, maxAge); + response.addCookie(cookie); + log.debug("Added secure cookie: {} with maxAge: {}", name, maxAge); + }, + () -> log.warn("Cannot add cookie '{}' - no HTTP response context available", name) + ); + } + + /** + * Removes a cookie by setting its max age to 0 and clearing its value. + * This method ensures proper cookie removal across different browsers. + * @param name the cookie name to remove + */ + public void removeCookie(String name) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + // Create removal cookie with both secure and non-secure variants + // to ensure removal regardless of original cookie settings + Cookie removeCookie = createRemovalCookie(name, false); + response.addCookie(removeCookie); + + // Also add secure variant for removal + Cookie secureRemoveCookie = createRemovalCookie(name, true); + response.addCookie(secureRemoveCookie); + + log.debug("Removed cookie: {}", name); + }, + () -> log.warn("Cannot remove cookie '{}' - no HTTP response context available", name) + ); + } + + /** + * Checks if a cookie with the given name exists in the current request + * @param name the cookie name to check + * @return true if cookie exists, false otherwise + */ + public boolean cookieExists(String name) { + return getCookie(name).isPresent(); + } + + /** + * Gets all cookies from the current request + * @return array of cookies, or empty array if none exist + */ + public Cookie[] getAllCookies() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getCookies) + .orElse(new Cookie[0]); + } + + /** + * Validates cookie name according to RFC standards + * @param name cookie name to validate + * @return true if valid cookie name + */ + public boolean isValidCookieName(String name) { + if (name == null || name.trim().isEmpty()) { + return false; + } + + // Basic validation - no spaces, control characters, or special chars + return name.matches("^[a-zA-Z0-9_-]+$"); + } + + /** + * Creates a secure cookie with comprehensive security settings + */ + private Cookie createSecureCookie(String name, String value, int maxAge) { + boolean isSecure = cookieProperties.isSecure(); + Cookie cookie = new Cookie(name, value); + + // Set domain only for production/secure environments + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); + cookie.setSecure(isSecure); + cookie.setMaxAge(maxAge); + + // Set SameSite attribute based on security requirements + String sameSite = isSecure ? + cookieProperties.getSameSiteSecure() : + cookieProperties.getSameSiteNonSecure(); + cookie.setAttribute("SameSite", sameSite); + + return cookie; + } + + /** + * Creates a cookie specifically for removal purposes + */ + private Cookie createRemovalCookie(String name, boolean isSecure) { + Cookie cookie = new Cookie(name, EMPTY_VALUE); + + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + cookie.setSecure(true); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); + cookie.setMaxAge(COOKIE_REMOVAL_MAX_AGE); + + return cookie; + } + + /** + * Helper method to find cookie by name in cookie array + */ + private Optional findCookieByName(Cookie[] cookies, String name) { + if (cookies == null) { + log.debug("No cookies found in request"); + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> name.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/HttpContextService.java b/src/main/java/com/juu/juulabel/common/http/HttpContextService.java new file mode 100644 index 00000000..7f69eb5e --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/HttpContextService.java @@ -0,0 +1,94 @@ +package com.juu.juulabel.common.http; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for accessing HTTP context (request/response) from the current thread. + * Provides safe access to servlet objects with proper error handling. + */ +@Slf4j +@Service +public class HttpContextService { + + /** + * Gets the current HTTP request from the servlet context + * @return the current HttpServletRequest + * @throws BaseException if request context is not available + */ + public HttpServletRequest getCurrentRequest() { + return getServletRequestAttributes() + .map(ServletRequestAttributes::getRequest) + .orElseThrow(() -> { + log.error("No HTTP request context available"); + return new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + }); + } + + /** + * Gets the current HTTP response from the servlet context + * @return the current HttpServletResponse + * @throws BaseException if request context is not available + */ + public HttpServletResponse getCurrentResponse() { + return getServletRequestAttributes() + .map(ServletRequestAttributes::getResponse) + .orElseThrow(() -> { + log.error("No HTTP response context available"); + return new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + }); + } + + /** + * Safely gets the current request if available + * @return Optional containing the request, or empty if not available + */ + public Optional getCurrentRequestOptional() { + try { + return Optional.of(getCurrentRequest()); + } catch (BaseException e) { + log.debug("HTTP request context not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Safely gets the current response if available + * @return Optional containing the response, or empty if not available + */ + public Optional getCurrentResponseOptional() { + try { + return Optional.of(getCurrentResponse()); + } catch (BaseException e) { + log.debug("HTTP response context not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Checks if HTTP context is currently available + * @return true if context is available, false otherwise + */ + public boolean isContextAvailable() { + return getServletRequestAttributes().isPresent(); + } + + /** + * Gets ServletRequestAttributes safely + */ + private Optional getServletRequestAttributes() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java b/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java new file mode 100644 index 00000000..6195d4a8 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java @@ -0,0 +1,232 @@ +package com.juu.juulabel.common.http; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.properties.RedirectProperties; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for HTTP response operations. + * Handles redirects, status codes, headers, and response manipulation. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HttpResponseService { + + private final HttpContextService httpContextService; + private final RedirectProperties redirectProperties; + + /** + * Redirects to the configured login URL + */ + public void redirectToLogin() { + redirect(redirectProperties.getLoginUrl()); + log.debug("Redirected to login page"); + } + + /** + * Redirects to the configured signup URL + */ + public void redirectToSignup() { + redirect(redirectProperties.getSignupUrl()); + log.debug("Redirected to signup page"); + } + + /** + * Redirects to the configured error URL + */ + public void redirectToError() { + redirect(redirectProperties.getErrorUrl()); + log.debug("Redirected to error page"); + } + + /** + * Performs redirect to specified URL + * @param url Target URL for redirect + * @throws BaseException if redirect fails or no response context available + */ + public void redirect(String url) { + if (url == null || url.trim().isEmpty()) { + log.error("Redirect URL is null or empty"); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + try { + response.sendRedirect(url); + log.debug("Successfully redirected to: {}", url); + } catch (IOException e) { + log.error("Failed to redirect to URL: {} - {}", url, e.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + }, + () -> { + log.error("Cannot redirect to '{}' - no HTTP response context available", url); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + ); + } + + /** + * Sets response status code + * @param status HTTP status to set + */ + public void setStatus(HttpStatus status) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setStatus(status.value()); + log.debug("Set response status to: {}", status); + }, + () -> log.warn("Cannot set status '{}' - no HTTP response context available", status) + ); + } + + /** + * Adds header to response + * @param name Header name + * @param value Header value + */ + public void addHeader(String name, String value) { + if (name == null || name.trim().isEmpty()) { + log.warn("Header name is null or empty"); + return; + } + + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.addHeader(name, value); + log.debug("Added header: {} = {}", name, value); + }, + () -> log.warn("Cannot add header '{}' - no HTTP response context available", name) + ); + } + + /** + * Sets content type for response + * @param contentType Content type to set + */ + public void setContentType(String contentType) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setContentType(contentType); + log.debug("Set content type to: {}", contentType); + }, + () -> log.warn("Cannot set content type '{}' - no HTTP response context available", contentType) + ); + } + + /** + * Sets content type using MediaType enum + * @param mediaType MediaType to set + */ + public void setContentType(MediaType mediaType) { + setContentType(mediaType.toString()); + } + + /** + * Sets character encoding for response + * @param encoding Character encoding to set + */ + public void setCharacterEncoding(String encoding) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setCharacterEncoding(encoding); + log.debug("Set character encoding to: {}", encoding); + }, + () -> log.warn("Cannot set character encoding '{}' - no HTTP response context available", encoding) + ); + } + + /** + * Writes content to response + * @param content Content to write + * @throws BaseException if writing fails + */ + public void writeContent(String content) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + try { + response.getWriter().write(content); + log.debug("Successfully wrote content to response (length: {})", content.length()); + } catch (IOException e) { + log.error("Failed to write content to response: {}", e.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + }, + () -> { + log.error("Cannot write content - no HTTP response context available"); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + ); + } + + /** + * Sets cache control headers to prevent caching + */ + public void setNoCache() { + addHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); + addHeader(HttpHeaders.PRAGMA, "no-cache"); + addHeader(HttpHeaders.EXPIRES, "0"); + log.debug("Set no-cache headers"); + } + + /** + * Sets cache control headers for specified max age + * @param maxAgeSeconds Maximum age in seconds + */ + public void setCacheMaxAge(int maxAgeSeconds) { + addHeader(HttpHeaders.CACHE_CONTROL, "max-age=" + maxAgeSeconds); + log.debug("Set cache max-age to: {} seconds", maxAgeSeconds); + } + + /** + * Checks if response is committed (headers already sent) + * @return true if response is committed + */ + public boolean isCommitted() { + return httpContextService.getCurrentResponseOptional() + .map(HttpServletResponse::isCommitted) + .orElse(true); // Assume committed if no context + } + + /** + * Gets current response status if available + * @return Optional containing status code + */ + public Optional getStatus() { + return httpContextService.getCurrentResponseOptional() + .map(HttpServletResponse::getStatus); + } + + /** + * Safely redirects with fallback error handling + * @param url Target URL + * @param fallbackUrl Fallback URL if primary fails + */ + public void safeRedirect(String url, String fallbackUrl) { + try { + redirect(url); + } catch (Exception e) { + log.warn("Primary redirect to '{}' failed, trying fallback: {}", url, e.getMessage()); + try { + redirect(fallbackUrl); + } catch (Exception fallbackException) { + log.error("Both primary and fallback redirects failed: {}", fallbackException.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/http/IpAddressService.java similarity index 74% rename from src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java rename to src/main/java/com/juu/juulabel/common/http/IpAddressService.java index 4f679bcd..be1498a6 100644 --- a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java +++ b/src/main/java/com/juu/juulabel/common/http/IpAddressService.java @@ -1,17 +1,29 @@ -package com.juu.juulabel.common.util; +package com.juu.juulabel.common.http; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import java.net.InetAddress; import java.net.UnknownHostException; import java.util.List; +import java.util.Optional; import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + /** - * Utility class for IP address extraction and validation + * Service for IP address extraction and validation. + * Handles reliable client IP detection with validation and reliability scoring. */ -public final class IpAddressExtractor extends AbstractHttpUtil { +@Slf4j +@Service +@RequiredArgsConstructor +public class IpAddressService { private static final String UNKNOWN = "unknown"; + + private final HttpContextService httpContextService; // Ordered by reliability - most trusted first private static final List IP_HEADER_CANDIDATES = List.of( @@ -39,27 +51,25 @@ public final class IpAddressExtractor extends AbstractHttpUtil { private static final Pattern IPV6_PATTERN = Pattern.compile( "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|:((:[0-9a-fA-F]{1,4}){1,7}|:)|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4})$"); - /** - * Private constructor to prevent instantiation - */ - private IpAddressExtractor() { - super(); - } - /** * Extract client IP address with validation and reliability checks - * * @return most reliable client IP address found */ - public static String getClientIpAddress() { - HttpServletRequest request = getCurrentRequest(); + public String getClientIpAddress() { + Optional requestOpt = httpContextService.getCurrentRequestOptional(); + if (requestOpt.isEmpty()) { + log.warn("No HTTP context available for IP extraction"); + return "unknown"; + } + + HttpServletRequest request = requestOpt.get(); return IP_HEADER_CANDIDATES.stream() .map(request::getHeader) .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) .map(ip -> ip.split(",")[0].trim()) // Take first IP from comma-separated list - .filter(IpAddressExtractor::isValidIpAddress) - .filter(IpAddressExtractor::isPublicIpAddress) // Prefer public IPs + .filter(this::isValidIpAddress) + .filter(this::isPublicIpAddress) // Prefer public IPs .findFirst() .orElseGet(() -> { // Fallback: try to get any valid IP (including private) @@ -67,7 +77,7 @@ public static String getClientIpAddress() { .map(request::getHeader) .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) .map(ip -> ip.split(",")[0].trim()) - .filter(IpAddressExtractor::isValidIpAddress) + .filter(this::isValidIpAddress) .findFirst() .orElseGet(request::getRemoteAddr); @@ -77,9 +87,16 @@ public static String getClientIpAddress() { /** * Get client IP with reliability score for monitoring/logging + * @return IP address information with reliability assessment */ - public static IpAddressInfo getClientIpAddressWithInfo() { - HttpServletRequest request = getCurrentRequest(); + public IpAddressInfo getClientIpAddressWithInfo() { + Optional requestOpt = httpContextService.getCurrentRequestOptional(); + if (requestOpt.isEmpty()) { + log.warn("No HTTP context available for IP extraction"); + return new IpAddressInfo("unknown", "NO_CONTEXT", ReliabilityLevel.LOW); + } + + HttpServletRequest request = requestOpt.get(); for (int i = 0; i < IP_HEADER_CANDIDATES.size(); i++) { String headerName = IP_HEADER_CANDIDATES.get(i); @@ -103,8 +120,10 @@ public static IpAddressInfo getClientIpAddressWithInfo() { /** * Validate if string is a valid IP address (IPv4 or IPv6) + * @param ip IP address string to validate + * @return true if valid IP address */ - private static boolean isValidIpAddress(String ip) { + public boolean isValidIpAddress(String ip) { if (ip == null || ip.trim().isEmpty()) { return false; } @@ -119,8 +138,10 @@ private static boolean isValidIpAddress(String ip) { /** * Check if IP address is public (not private/local) + * @param ip IP address to check + * @return true if public IP address */ - private static boolean isPublicIpAddress(String ip) { + public boolean isPublicIpAddress(String ip) { if (!isValidIpAddress(ip)) { return false; } @@ -130,8 +151,10 @@ private static boolean isPublicIpAddress(String ip) { /** * Check if IP is in private ranges + * @param ip IP address to check + * @return true if private IP address */ - private static boolean isPrivateIpAddress(String ip) { + public boolean isPrivateIpAddress(String ip) { // Check IPv6 private ranges first if (ip.contains(":")) { return isPrivateIpv6(ip); @@ -146,7 +169,7 @@ private static boolean isPrivateIpAddress(String ip) { return false; } - private static boolean isPrivateIpv6(String ip) { + private boolean isPrivateIpv6(String ip) { try { InetAddress addr = InetAddress.getByName(ip); return addr.isSiteLocalAddress() @@ -161,7 +184,7 @@ private static boolean isPrivateIpv6(String ip) { /** * Check if 172.x.x.x IP is in private range (172.16.0.0 to 172.31.255.255) */ - private static boolean isPrivate172Range(String ip) { + private boolean isPrivate172Range(String ip) { String[] octets = ip.split("\\."); if (octets.length < 2) { return false; @@ -178,11 +201,11 @@ private static boolean isPrivate172Range(String ip) { /** * Check if IP is localhost or other special addresses */ - private static boolean isSpecialAddress(String ip) { + private boolean isSpecialAddress(String ip) { return ip.equals("127.0.0.1") || ip.equals("::1") || ip.equals("0.0.0.0"); } - private static ReliabilityLevel getReliabilityLevel(String headerName, String ip) { + private ReliabilityLevel getReliabilityLevel(String headerName, String ip) { // Rate headers by trustworthiness return switch (headerName) { case "CF-Connecting-IP", "True-Client-IP" -> ReliabilityLevel.HIGH; @@ -217,6 +240,12 @@ public String getSourceHeader() { public ReliabilityLevel getReliability() { return reliability; } + + @Override + public String toString() { + return String.format("IpAddressInfo{ip='%s', source='%s', reliability=%s}", + ipAddress, sourceHeader, reliability); + } } public enum ReliabilityLevel { @@ -224,4 +253,4 @@ public enum ReliabilityLevel { MEDIUM, // Nginx, proper proxies - generally reliable LOW // Easy to spoof headers - use with caution } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java b/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java new file mode 100644 index 00000000..ebab0464 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java @@ -0,0 +1,143 @@ +package com.juu.juulabel.common.http; + +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for extracting various data from HTTP requests. + * Handles header extraction, path matching, and parameter retrieval. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RequestDataExtractor { + + private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; + + private final HttpContextService httpContextService; + + /** + * Checks if the current request path matches the given prefix + * @param pathPrefix Path prefix to match against + * @return true if path matches, false otherwise + */ + public boolean isPathMatch(String pathPrefix) { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRequestURI) + .map(uri -> uri.startsWith(pathPrefix)) + .orElse(false); + } + + /** + * Extracts Authorization header from current request + * @return Authorization header value, or null if not present + */ + public Optional getAuthorizationHeader() { + return getHeaderValue(HttpHeaders.AUTHORIZATION); + } + + /** + * Extracts User-Agent header from current request + * @return User-Agent header value, or null if not present + */ + public Optional getUserAgent() { + return getHeaderValue(HttpHeaders.USER_AGENT); + } + + /** + * Extracts device ID from request headers with fallback to parameter + * @return Device ID value + * @throws BaseException if Device-Id is missing or empty + */ + public String getDeviceId() { + HttpServletRequest request = httpContextService.getCurrentRequest(); + + // Try header first + String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); + + // Fallback to state parameter + if (!StringUtils.hasText(deviceId)) { + deviceId = request.getParameter("state"); + } + + if (!StringUtils.hasText(deviceId)) { + log.warn("Device-Id not found in headers or parameters for request: {}", + request.getRequestURI()); + throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); + } + + return deviceId.trim(); + } + + /** + * Safely extracts device ID without throwing exception + * @return Optional containing device ID if present + */ + public Optional getDeviceIdOptional() { + try { + return Optional.of(getDeviceId()); + } catch (BaseException e) { + log.debug("Device ID not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Gets a specific header value from the current request + * @param headerName Name of the header to retrieve + * @return Optional containing header value if present + */ + public Optional getHeaderValue(String headerName) { + return httpContextService.getCurrentRequestOptional() + .map(request -> request.getHeader(headerName)) + .filter(StringUtils::hasText); + } + + /** + * Gets a specific parameter value from the current request + * @param parameterName Name of the parameter to retrieve + * @return Optional containing parameter value if present + */ + public Optional getParameterValue(String parameterName) { + return httpContextService.getCurrentRequestOptional() + .map(request -> request.getParameter(parameterName)) + .filter(StringUtils::hasText); + } + + /** + * Gets the current request URI + * @return Optional containing request URI if available + */ + public Optional getRequestURI() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRequestURI); + } + + /** + * Gets the current request method + * @return Optional containing request method if available + */ + public Optional getRequestMethod() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getMethod); + } + + /** + * Gets the remote address from the request + * @return Optional containing remote address if available + */ + public Optional getRemoteAddress() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRemoteAddr); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java index 37cdf8d6..ad15f2b6 100644 --- a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java +++ b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java @@ -28,9 +28,9 @@ public class CookieProperties { /** * Default path for cookies. - * Default: /app + * Default: / */ - private String path = "/app"; + private String path = "/"; /** * Default SameSite attribute for secure cookies. diff --git a/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java b/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java new file mode 100644 index 00000000..5c26fafd --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java @@ -0,0 +1,38 @@ +package com.juu.juulabel.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Data; + +import com.juu.juulabel.member.domain.Provider; + +@Data +@Component +@ConfigurationProperties(prefix = "app.redirect") +public class RedirectProperties { + + private String baseServer; + private String baseClient; + private String callback; + private String login; + private String signup; + private String error; + + public String getRedirectUrl(Provider provider) { + return baseServer + callback + "/" + provider.name().toLowerCase(); + } + + public String getLoginUrl() { + return baseClient + login; + } + + public String getSignupUrl() { + return baseClient + signup; + } + + public String getErrorUrl() { + return baseClient + error; + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java deleted file mode 100644 index b694d436..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.juu.juulabel.common.provider.jwt; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.Collections; - -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.MemberRole; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; - -@Component -public class AccessTokenProvider extends MemberTokenProvider { - - public AccessTokenProvider(@Value("${spring.jwt.access-key}") String secretKey) { - super(secretKey); - } - - public String createToken(Member member) { - return this.createToken(member, AuthConstants.ACCESS_TOKEN_DURATION); - } - - @Override - public String createToken(Member member, Duration duration) { - return super.createToken(member, duration); - } - - public Authentication getAuthentication(String accessToken) { - return extractFromClaims(accessToken, claims -> { - String role = claims.get(ROLE_CLAIM, String.class); - Long memberId = Long.parseLong(claims.getSubject()); - - Member member = Member.builder() - .id(memberId) - .role(MemberRole.valueOf(role)) - .build(); - - return new UsernamePasswordAuthenticationToken( - member, - null, - Collections.singletonList(new SimpleGrantedAuthority(role))); - }); - } -} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java deleted file mode 100644 index 11b63249..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.juu.juulabel.common.provider.jwt; - -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.constants.AuthConstants; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; - -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import javax.crypto.SecretKey; -import java.util.*; -import java.util.function.Function; - -@Component -public abstract class JwtTokenProvider { - protected static final String ISSUER = "juulabel"; - protected static final String ROLE_CLAIM = "role"; - - protected final SecretKey key; - protected final JwtParser jwtParser; - - protected JwtTokenProvider(String secretKey) { - this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)); - this.jwtParser = Jwts.parser().verifyWith(this.key).build(); - } - - public String resolveToken(String header) { - if (!StringUtils.hasText(header)) { - throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); - } - return header.replace(AuthConstants.TOKEN_PREFIX, ""); - } - - protected T extractFromClaims(String token, Function claimsResolver) { - return claimsResolver.apply(parseClaims(token)); - } - - protected Claims parseClaims(String token) { - try { - return jwtParser.parseSignedClaims(token).getPayload(); - } catch (SignatureException | MalformedJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); - } catch (ExpiredJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_EXPIRED_EXCEPTION); - } catch (UnsupportedJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_UNSUPPORTED_EXCEPTION); - } catch (IllegalArgumentException ex) { - throw new CustomJwtException(ErrorCode.JWT_ILLEGAL_ARGUMENT_EXCEPTION); - } - } - -} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java deleted file mode 100644 index 3f631683..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.common.provider.jwt; - -import java.time.Duration; -import java.util.Date; - -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.MemberRole; - -import io.jsonwebtoken.Jwts; - -public abstract class MemberTokenProvider extends JwtTokenProvider { - - protected MemberTokenProvider(String secretKey) { - super(secretKey); - } - - public String createToken(Member member, Duration duration) { - return Jwts.builder() - .subject(String.valueOf(member.getId())) - .claim(ROLE_CLAIM, member.getRole().name()) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + duration.toMillis())) - .signWith(key) - .compact(); - } - - public Member getMemberFromToken(String token) { - return extractFromClaims(token, claims -> { - Long memberId = Long.parseLong(claims.getSubject()); - String role = claims.get(ROLE_CLAIM, String.class); - - return Member.builder() - .id(memberId) - .role(role != null ? MemberRole.valueOf(role) : MemberRole.ROLE_USER) - .build(); - }); - } - -} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java deleted file mode 100644 index 38fba8d8..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.juu.juulabel.common.provider.jwt; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.util.HashingUtil; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.common.util.IpAddressExtractor; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; - -@Component -public class RefreshTokenProvider extends MemberTokenProvider { - - public RefreshTokenProvider(@Value("${spring.jwt.refresh-key}") String secretKey) { - super(secretKey); - } - - public RefreshToken buildRefreshToken(Member member) { - String token = createToken(member, AuthConstants.REFRESH_TOKEN_DURATION); - String hashedToken = HashingUtil.hashSha256(token); - - return RefreshToken.builder() - .token(token) - .hashedToken(hashedToken) - .memberId(member.getId()) - .clientId(ClientId.WEB) - .deviceId(HttpRequestUtil.getDeviceId()) - .ipAddress(IpAddressExtractor.getClientIpAddress()) - .userAgent(HttpRequestUtil.getUserAgent()) - .build(); - } -} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java deleted file mode 100644 index 427d92ad..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.juu.juulabel.common.provider.jwt; - -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; - -import org.springframework.stereotype.Component; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; - -import com.juu.juulabel.auth.domain.SignUpToken; -import com.juu.juulabel.auth.service.SocialLinkService; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; - -@Component -public class SignupTokenProvider extends JwtTokenProvider { - - private final SocialLinkService socialLinkService; - - public SignupTokenProvider(@Value("${spring.jwt.signup-key}") String secretKey, - SocialLinkService socialLinkService) { - super(secretKey); - this.socialLinkService = socialLinkService; - } - - public String createToken(OAuthUser oAuthUser, String nonce) { - String email = oAuthUser.email(); - Provider provider = oAuthUser.provider(); - String providerId = oAuthUser.id(); - Map claims = new HashMap<>(); - claims.put("email", email); - claims.put("provider", provider.name()); - claims.put("providerId", providerId); - claims.put("nonce", nonce); - claims.put("aud", "user-signup-completion"); - return Jwts.builder() - .claims(claims) - .issuedAt(new Date()) - .issuer(ISSUER) - .expiration(new Date(System.currentTimeMillis() + AuthConstants.SIGN_UP_TOKEN_DURATION.toMillis())) - .signWith(key) - .compact(); - } - - public Authentication getAuthentication(String token) { - - return extractFromClaims(token, claims -> { - SignUpToken signUpToken = buildSignUpToken(token); - socialLinkService.verify(signUpToken); - - return new UsernamePasswordAuthenticationToken(signUpToken, null, - Collections.emptyList()); - }); - } - - public SignUpToken buildSignUpToken(String token) { - Claims claims = parseClaims(token); - String email = getClaimAsString(claims, "email"); - Provider provider = Provider.valueOf(getClaimAsString(claims, "provider")); - String providerId = getClaimAsString(claims, "providerId"); - String nonce = getClaimAsString(claims, "nonce"); - String aud = getClaimAsString(claims, "aud"); - - if (!"[user-signup-completion]".equals(aud)) { - throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); - } - - return new SignUpToken(token, email, provider, providerId, nonce); - } - - /** - * Safely extract claim as string, handling potential collection types - */ - private String getClaimAsString(Claims claims, String claimName) { - Object claimValue = claims.get(claimName); - if (claimValue == null) { - return null; - } - return claimValue.toString(); - } - -} diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java new file mode 100644 index 00000000..e28a95f7 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java @@ -0,0 +1,49 @@ +package com.juu.juulabel.common.provider.oauth; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.client.AppleAuthClient; +import com.juu.juulabel.auth.service.AppleTokenService; +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.token.OAuthToken; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AppleProvider implements OAuthProvider { + + private final AppleAuthClient appleAuthClient; + private final AppleTokenService appleTokenService; + + @Value("${spring.security.oauth2.client.registration.apple.authorization-grant-type}") + private String grantType; + + @Value("${spring.security.oauth2.client.registration.apple.clientId}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.apple.clientSecret}") + private String clientSecret; + + @Override + public OAuthToken getOAuthToken(String redirectUri, String code) { + return appleAuthClient.generateOAuthToken( + code, + clientId, + clientSecret, + redirectUri, + grantType); + } + + @Override + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + List publicKeys = appleAuthClient.getApplePublicKeys(); + + return appleTokenService.extractAppleUser(publicKeys, oauthToken); + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java index b36ed118..4d1ee311 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java @@ -2,7 +2,6 @@ import com.juu.juulabel.common.client.GoogleApiClient; import com.juu.juulabel.common.client.GoogleAuthClient; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; import lombok.RequiredArgsConstructor; @@ -32,21 +31,17 @@ public class GoogleProvider implements OAuthProvider { public OAuthToken getOAuthToken(String redirectUri, String code) { String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); // 구글 oauth 서버로부터 받은 인가코드는 디코딩 해줘야 함 return googleAuthClient.generateOAuthToken( - decodedCode, - clientId, - clientSecret, - redirectUri, - grantType - ); + decodedCode, + clientId, + clientSecret, + redirectUri, + grantType); } @Override - public OAuthUser getOAuthUser(String accessToken) { - return googleApiClient.getUserInfo(getBearerToken(accessToken)); - } - - private String getBearerToken(String accessToken) { - return AuthConstants.TOKEN_PREFIX + accessToken; + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + String accessToken = getBearerToken(oauthToken.accessToken()); + return googleApiClient.getUserInfo(accessToken); } } diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java index 8cf3a093..35aca3f6 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java @@ -2,7 +2,6 @@ import com.juu.juulabel.common.client.KakaoApiClient; import com.juu.juulabel.common.client.KakaoAuthClient; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; import lombok.RequiredArgsConstructor; @@ -32,17 +31,12 @@ public OAuthToken getOAuthToken(String redirectUri, String code) { clientId, redirectUri, code, - clientSecret - ); + clientSecret); } @Override - public OAuthUser getOAuthUser(String accessToken) { - return kakaoApiClient.getUserInfo(getBearerToken(accessToken)); - } - - private String getBearerToken(String accessToken) { - return AuthConstants.TOKEN_PREFIX + accessToken; + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + return kakaoApiClient.getUserInfo(getBearerToken(oauthToken.accessToken())); } } diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java index 2176c0fc..8296b92a 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.provider.oauth; +import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; @@ -7,5 +8,9 @@ public interface OAuthProvider { OAuthToken getOAuthToken(String redirectUri, String code); - OAuthUser getOAuthUser(String accessToken); + OAuthUser getOAuthUser(OAuthToken oauthToken); + + default String getBearerToken(String accessToken) { + return AuthConstants.TOKEN_PREFIX + accessToken; + } } diff --git a/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java new file mode 100644 index 00000000..ca9d6a5a --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java @@ -0,0 +1,31 @@ +package com.juu.juulabel.common.provider.token; + +import java.util.Map; +import java.util.function.Function; + +/** + * Generic token service interface that separates token operations + * from specific validation and business logic. + */ +public interface TokenService { + + /** + * Creates a token with the provided claims + */ + String createToken(Map claimsMap); + + /** + * Parses and validates a token, returning the claims + */ + T parseToken(String token); + + /** + * Extracts specific information from token claims + */ + R extractFromToken(String token, Function extractor); + + /** + * Validates if a token is structurally valid + */ + boolean isValidToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java new file mode 100644 index 00000000..50e87082 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java @@ -0,0 +1,99 @@ +package com.juu.juulabel.common.provider.token.jwt; + +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.token.TokenService; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; + +/** + * JWT-specific implementation of TokenService + */ +public class JwtTokenService implements TokenService { + + public static final String DEFAULT_ISSUER = "juulabel.com"; + + private final SecretKey key; + private final JwtParser jwtParser; + private final Duration tokenDuration; + + public JwtTokenService(String secretKey, Duration duration) { + this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)); + this.jwtParser = Jwts.parser().verifyWith(this.key).build(); + this.tokenDuration = duration; + } + + + + /** + * Creates a token with custom claims map + */ + @Override + public String createToken(Map claimsMap) { + return Jwts.builder() + .claims(claimsMap) + .issuer(DEFAULT_ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + tokenDuration.toMillis())) + .signWith(key) + .compact(); + } + + /** + * Creates a JWT builder with claims for more advanced token creation + */ + public JwtBuilder createBuilder(Map claims) { + return Jwts.builder() + .claims(claims) + .issuer(DEFAULT_ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + tokenDuration.toMillis())) + .signWith(key); + } + + @Override + public Claims parseToken(String token) { + try { + return jwtParser.parseSignedClaims(token).getPayload(); + } catch (SignatureException | MalformedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); + } catch (ExpiredJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_EXPIRED_EXCEPTION); + } catch (UnsupportedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (IllegalArgumentException ex) { + throw new CustomJwtException(ErrorCode.JWT_ILLEGAL_ARGUMENT_EXCEPTION); + } + } + + @Override + public R extractFromToken(String token, Function extractor) { + return extractor.apply(parseToken(token)); + } + + @Override + public boolean isValidToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java new file mode 100644 index 00000000..e19f4dfa --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java @@ -0,0 +1,96 @@ +package com.juu.juulabel.common.provider.token.paseto; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +import com.juu.juulabel.common.exception.CustomPasetoException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.token.TokenService; + +import dev.paseto.jpaseto.Claims; +import dev.paseto.jpaseto.ExpiredPasetoException; +import dev.paseto.jpaseto.IncorrectClaimException; +import dev.paseto.jpaseto.MissingClaimException; +import dev.paseto.jpaseto.PasetoException; +import dev.paseto.jpaseto.PasetoIOException; +import dev.paseto.jpaseto.PasetoKeyException; +import dev.paseto.jpaseto.PasetoParser; +import dev.paseto.jpaseto.PasetoSignatureException; +import dev.paseto.jpaseto.PasetoV2LocalBuilder; +import dev.paseto.jpaseto.Pasetos; +import dev.paseto.jpaseto.RequiredTypeException; +import dev.paseto.jpaseto.lang.Keys; + +/** + * PASETO-specific implementation of TokenService + */ +public class PasetoTokenService implements TokenService { + + public static final String DEFAULT_ISSUER = "juulabel.com"; + public static final String DEFAULT_AUDIENCE = "juu-label-client"; + + private final SecretKey secretKey; + private final PasetoParser parser; + private final Duration tokenDuration; + + public PasetoTokenService(String secretKey, Duration duration) { + this.secretKey = Keys.secretKey(secretKey.getBytes()); + this.parser = Pasetos.parserBuilder() + .setSharedSecret(this.secretKey) + .build(); + this.tokenDuration = duration; + } + + /** + * Creates a token with custom claims map + */ + @Override + public String createToken(Map claimsMap) { + PasetoV2LocalBuilder builder = Pasetos.V2.LOCAL.builder() + .setSharedSecret(secretKey) + .setIssuer(DEFAULT_ISSUER) + .setIssuedAt(Instant.now()) + .setExpiration(Instant.now().plus(tokenDuration)); + + // Add custom claims + claimsMap.forEach(builder::claim); + + return builder.compact(); + } + + @Override + public Claims parseToken(String token) { + try { + return parser.parse(token).getClaims(); + } catch (ExpiredPasetoException e) { + throw new CustomPasetoException(ErrorCode.PAS_EXPIRED_EXCEPTION); + } catch (PasetoSignatureException | PasetoKeyException e) { + throw new CustomPasetoException(ErrorCode.PAS_SECURITY_EXCEPTION); + } catch (PasetoIOException e) { + throw new CustomPasetoException(ErrorCode.PAS_IO_EXCEPTION); + } catch (MissingClaimException | IncorrectClaimException | RequiredTypeException e) { + throw new CustomPasetoException(ErrorCode.PAS_ILLEGAL_ARGUMENT_EXCEPTION); + } catch (PasetoException e) { + throw new CustomPasetoException(ErrorCode.PAS_UNSUPPORTED_EXCEPTION); + } + } + + @Override + public R extractFromToken(String token, Function extractor) { + return extractor.apply(parseToken(token)); + } + + @Override + public boolean isValidToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java new file mode 100644 index 00000000..2049546d --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java @@ -0,0 +1,34 @@ +package com.juu.juulabel.common.provider.token.validator; + +import java.util.Map; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Provider; + +/** + * Record to hold signup token claims + */ +public record SignupTokenClaims( + String email, + Provider provider, + String providerId, + String nonce, + String audience) { + public static SignupTokenClaims from(Map claims) { + return new SignupTokenClaims( + getRequiredClaim(claims, "email"), + Provider.valueOf(getRequiredClaim(claims, "provider")), + getRequiredClaim(claims, "providerId"), + getRequiredClaim(claims, "nonce"), + getRequiredClaim(claims, "aud")); + } + + private static String getRequiredClaim(Map claims, String claimName) { + Object value = claims.get(claimName); + if (value == null) { + throw new AuthException("Missing required claim: " + claimName, ErrorCode.INVALID_AUTHENTICATION); + } + return value.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java new file mode 100644 index 00000000..d2bd2643 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java @@ -0,0 +1,73 @@ +package com.juu.juulabel.common.provider.token.validator; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; + +import lombok.RequiredArgsConstructor; + +/** + * Validator for signup token claims and associated member data + */ +@Component +@RequiredArgsConstructor +public class SignupTokenValidator implements TokenValidator { + + private static final String EXPECTED_AUDIENCE = "user-signup-completion"; + + private final MemberReader memberReader; + + @Override + public void validate(SignupTokenClaims claims) { + validateAudience(claims.audience()); + Member member = memberReader.getByEmail(claims.email()); + validateMemberAgainstClaims(member, claims); + } + + @Override + public String getValidationType() { + return "SIGNUP_TOKEN"; + } + + private void validateAudience(String audience) { + if (!EXPECTED_AUDIENCE.equals(audience)) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private void validateMemberAgainstClaims(Member member, SignupTokenClaims claims) { + validateProvider(member, claims); + validateProviderId(member, claims); + validateNonce(member, claims); + validateMemberStatus(member); + } + + private void validateProvider(Member member, SignupTokenClaims claims) { + if (member.getProvider() != claims.provider()) { + throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + } + + private void validateProviderId(Member member, SignupTokenClaims claims) { + if (!member.getProviderId().equals(claims.providerId())) { + throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + } + + private void validateNonce(Member member, SignupTokenClaims claims) { + if (!member.getNickname().equals(claims.nonce())) { + throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private void validateMemberStatus(Member member) { + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java new file mode 100644 index 00000000..ed2d5a24 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java @@ -0,0 +1,20 @@ +package com.juu.juulabel.common.provider.token.validator; + +/** + * Interface for validating token claims against business rules + */ +public interface TokenValidator { + + /** + * Validates the token claims + * + * @param claims the parsed token claims + * @throws RuntimeException if validation fails + */ + void validate(T claims); + + /** + * Returns the validation type for logging/debugging + */ + String getValidationType(); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java b/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java new file mode 100644 index 00000000..5e585b6e --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java @@ -0,0 +1,36 @@ +package com.juu.juulabel.common.session; + +import java.util.Collections; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.token.UserSession; + +/** + * Service for creating Spring Security Authentication objects from sessions + */ +@Component +public class SessionAuthenticationProvider { + + /** + * Creates Spring Security Authentication from UserSession + * @param session The user session + * @return Authentication object + */ + public Authentication createAuthentication(UserSession session) { + Member member = Member.builder() + .id(session.getMemberId()) + .role(session.getRole()) + .email(session.getEmail()) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + Collections.singletonList(new SimpleGrantedAuthority(session.getRole().name()))); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionService.java b/src/main/java/com/juu/juulabel/common/session/SessionService.java new file mode 100644 index 00000000..8f100c52 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionService.java @@ -0,0 +1,42 @@ +package com.juu.juulabel.common.session; + +import java.util.Optional; + +/** + * Generic session management interface - technology agnostic + * + * @param Session entity type + * @param Session identifier type + */ +public interface SessionService { + + /** + * Creates a new session + */ + T createSession(T session); + + /** + * Retrieves a session by ID + */ + Optional getSession(ID sessionId); + + /** + * Updates an existing session + */ + T updateSession(T session); + + /** + * Deletes a session by ID + */ + void deleteSession(ID sessionId); + + /** + * Deletes all sessions for a specific user + */ + void deleteAllUserSessions(Long userId); + + /** + * Checks if a session exists + */ + boolean sessionExists(ID sessionId); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java b/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java new file mode 100644 index 00000000..932afcda --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java @@ -0,0 +1,53 @@ +package com.juu.juulabel.common.session; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.function.Predicate; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import lombok.extern.slf4j.Slf4j; + +/** + * Service for generating cryptographically secure session tokens + */ +@Slf4j +@Component +public class SessionTokenGenerator { + + private static final int TOKEN_LENGTH = 32; + private static final int MAX_RETRY_ATTEMPTS = 3; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * Generates a unique session token with collision detection + * @param existenceChecker Function to check if a token already exists + * @return Unique session token + */ + public String generateUniqueToken(Predicate existenceChecker) { + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + String token = generateSecureToken(); + + if (!existenceChecker.test(token)) { + return token; + } + + log.warn("Session token collision detected, retrying... Attempt: {}", attempt + 1); + } + + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + /** + * Generates a simple secure token without collision detection + */ + public String generateSecureToken() { + byte[] tokenBytes = new byte[TOKEN_LENGTH]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java deleted file mode 100644 index 1db32c86..00000000 --- a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import java.util.Optional; -import java.util.function.Function; - -/** - * Abstract base class for HTTP utility operations - */ -public abstract class AbstractHttpUtil { - - protected AbstractHttpUtil() { - } - - /** - * Gets the current HTTP request from the servlet context - * - * @return the current HttpServletRequest - * @throws BaseException if request attributes are not available - */ - protected static HttpServletRequest getCurrentRequest() { - return getFromRequestAttributes(ServletRequestAttributes::getRequest); - } - - /** - * Gets the current HTTP response from the servlet context - * - * @return the current HttpServletResponse - * @throws BaseException if request attributes are not available - */ - protected static HttpServletResponse getCurrentResponse() { - return getFromRequestAttributes(ServletRequestAttributes::getResponse); - } - - /** - * Extracts data from ServletRequestAttributes using the provided function - * - * @param extractor function to extract data from ServletRequestAttributes - * @return extracted data - * @throws BaseException if request attributes are not available - */ - protected static T getFromRequestAttributes(Function extractor) { - return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) - .filter(ServletRequestAttributes.class::isInstance) - .map(ServletRequestAttributes.class::cast) - .map(extractor) - .orElseThrow(() -> new BaseException(ErrorCode.INTERNAL_SERVER_ERROR)); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java deleted file mode 100644 index b8d59979..00000000 --- a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.juu.juulabel.common.util; - -import com.juu.juulabel.common.properties.CookieProperties; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.util.Arrays; -import java.util.Optional; - -import org.springframework.stereotype.Component; - -/** - * Utility class for secure cookie management operations. - * Provides methods for creating, retrieving, and removing HTTP cookies with - * security best practices. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public final class CookieUtil extends AbstractHttpUtil { - - // Cookie removal configuration - private static final int COOKIE_REMOVAL_MAX_AGE = 0; - private static final String EMPTY_VALUE = ""; - - private final CookieProperties cookieProperties; - - /** - * Retrieves a cookie value by name from the current HTTP request. - * - * @param name the cookie name to search for - * @return the cookie value if found, null otherwise - */ - public String getCookie(String name) { - HttpServletRequest request = getCurrentRequest(); - Cookie[] cookies = request.getCookies(); - - if (cookies == null) { - log.debug("No cookies found in request"); - return null; - } - - return Arrays.stream(cookies) - .filter(cookie -> name.equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - } - - /** - * Retrieves a cookie as an Optional to avoid null pointer exceptions. - * - * @param name the cookie name to search for - * @return Optional containing the cookie value if found, empty otherwise - */ - public Optional getCookieOptional(String name) { - return Optional.ofNullable(getCookie(name)); - } - - /** - * Adds a secure HTTP-only cookie to the response with comprehensive security - * settings. - * - * @param name the cookie name - * @param value the cookie value - * @param maxAge the cookie max age in seconds - */ - public void addCookie(String name, String value, int maxAge) { - HttpServletResponse response = getCurrentResponse(); - Cookie cookie = createSecureCookie(name, value, maxAge); - response.addCookie(cookie); - } - - /** - * Adds a cookie with default security settings from configuration. - * - * @param name the cookie name - * @param value the cookie value - * @param maxAge the cookie max age in seconds - */ - public void addSecureCookie(String name, String value, int maxAge) { - addCookie(name, value, maxAge); - } - - /** - * Removes a cookie by setting its max age to 0 and clearing its value. - * This method ensures proper cookie removal across different browsers. - * - * @param name the cookie name to remove - */ - public void removeCookie(String name) { - HttpServletResponse response = getCurrentResponse(); - - // Create removal cookie with both secure and non-secure variants - // to ensure removal regardless of original cookie settings - Cookie removeCookie = createRemovalCookie(name, false); - response.addCookie(removeCookie); - - // Also add secure variant for removal - Cookie secureRemoveCookie = createRemovalCookie(name, true); - response.addCookie(secureRemoveCookie); - } - - /** - * Checks if a cookie with the given name exists in the current request. - * - * @param name the cookie name to check - * @return true if cookie exists, false otherwise - */ - public boolean cookieExists(String name) { - return getCookie(name) != null; - } - - /** - * Creates a secure cookie with comprehensive security settings. - */ - private Cookie createSecureCookie(String name, String value, int maxAge) { - boolean isSecure = cookieProperties.isSecure(); - Cookie cookie = new Cookie(name, value); - - // Set domain only for production/secure environments - if (isSecure) { - cookie.setDomain(cookieProperties.getDomain()); - } - - cookie.setPath(cookieProperties.getPath()); - cookie.setHttpOnly(cookieProperties.isHttpOnly()); - cookie.setSecure(isSecure); - cookie.setMaxAge(maxAge); - - // Set SameSite attribute based on security requirements - String sameSite = isSecure ? cookieProperties.getSameSiteSecure() : cookieProperties.getSameSiteNonSecure(); - cookie.setAttribute("SameSite", sameSite); - - return cookie; - } - - /** - * Creates a cookie specifically for removal purposes. - */ - private Cookie createRemovalCookie(String name, boolean isSecure) { - Cookie cookie = new Cookie(name, EMPTY_VALUE); - - if (isSecure) { - cookie.setDomain(cookieProperties.getDomain()); - cookie.setSecure(true); - } - - cookie.setPath(cookieProperties.getPath()); - cookie.setHttpOnly(cookieProperties.isHttpOnly()); - cookie.setMaxAge(COOKIE_REMOVAL_MAX_AGE); - - return cookie; - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/HashingUtil.java b/src/main/java/com/juu/juulabel/common/util/HashingUtil.java deleted file mode 100644 index 8353baf1..00000000 --- a/src/main/java/com/juu/juulabel/common/util/HashingUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.juu.juulabel.common.util; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; - -public final class HashingUtil { - - private HashingUtil() { - throw new AssertionError("Utility class should not be instantiated"); - } - - public static String hashSha256(String input) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashedBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } -} diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java deleted file mode 100644 index 34de9809..00000000 --- a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.http.HttpHeaders; - -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import jakarta.servlet.http.HttpServletRequest; - -public class HttpRequestUtil extends AbstractHttpUtil { - - private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; - - private HttpRequestUtil() { - super(); - } - - public static boolean isPathMatch(String path) { - return getCurrentRequest().getRequestURI().startsWith(path); - } - - public static String getAuthorization() { - HttpServletRequest request = getCurrentRequest(); - return request.getHeader(HttpHeaders.AUTHORIZATION); - } - - /** - * Extract device ID from request headers - * - * @return device ID from Device-Id header - * @throws BaseException if Device-Id header is missing or empty - */ - public static String getDeviceId() { - HttpServletRequest request = getCurrentRequest(); - String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); - if (deviceId == null || deviceId.trim().isEmpty()) { - throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); - } - return deviceId.trim(); - } - - /** - * Extract user agent from request headers - * - * @return user agent string from User-Agent header - */ - - public static String getUserAgent() { - HttpServletRequest request = getCurrentRequest(); - return request.getHeader(HttpHeaders.USER_AGENT); - } -} diff --git a/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java b/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java new file mode 100644 index 00000000..6609b362 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java @@ -0,0 +1,57 @@ +package com.juu.juulabel.common.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.response.CommonResponse; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class SecurityResponseUtil { + + private final ObjectMapper objectMapper; + private static final String UTF_8 = "UTF-8"; + + /** + * Sets standardized error response for security-related exceptions + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + ErrorCode errorCode, String message) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(errorCode, message)); + setResponse(response, status, responseBody); + } + + /** + * Sets standardized error response with default error message + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + ErrorCode errorCode) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(errorCode)); + setResponse(response, status, responseBody); + } + + /** + * Sets standardized error response for runtime exceptions + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + RuntimeException exception) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(exception)); + setResponse(response, status, responseBody); + } + + private void setResponse(HttpServletResponse response, HttpStatus status, String body) throws IOException { + response.setCharacterEncoding(UTF_8); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(body); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/domain/Member.java b/src/main/java/com/juu/juulabel/member/domain/Member.java index dc7e71e5..c6fb98aa 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -6,7 +6,6 @@ import com.juu.juulabel.common.exception.BaseException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.auth.domain.SignUpToken; import com.juu.juulabel.common.base.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -77,16 +76,23 @@ public class Member extends BaseTimeEntity { @Column(name = "deleted_at", columnDefinition = "datetime comment '탈퇴 일시'") private LocalDateTime deletedAt; - public static Member create(SignUpMemberRequest signUpMemberRequest, SignUpToken signUpToken) { + public void completeSignUp(SignUpMemberRequest signUpMemberRequest) { + this.nickname = signUpMemberRequest.nickname(); + this.gender = signUpMemberRequest.gender(); + this.role = MemberRole.ROLE_USER; + this.status = MemberStatus.ACTIVE; + } + + public static Member create(OAuthUser oAuthUser, String nonce) { return Member.builder() - .email(signUpToken.email()) - .nickname(signUpMemberRequest.nickname()) - .gender(signUpMemberRequest.gender()) - .provider(signUpToken.provider()) - .providerId(signUpToken.providerId()) - .status(MemberStatus.ACTIVE) + .email(oAuthUser.email()) + .nickname(nonce) + .gender(Gender.NONE) + .provider(oAuthUser.provider()) + .providerId(oAuthUser.id()) .hasBadge(false) - .role(MemberRole.ROLE_USER) + .role(MemberRole.ROLE_GUEST) + .status(MemberStatus.PENDING) .build(); } @@ -106,18 +112,6 @@ public boolean isSameUser(Member other) { return this.equals(other); } - public void validateLoginMember(OAuthUser oAuthUser) { - if (this.deletedAt != null) { - throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); - } - if (!this.provider.equals(oAuthUser.provider())) { - throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } - if (!this.providerId.equals(oAuthUser.id())) { - throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); - } - } - @Override public boolean equals(Object obj) { if (this == obj) diff --git a/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java b/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java index 5f779a3b..82d77e4e 100644 --- a/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java +++ b/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java @@ -4,5 +4,5 @@ @Getter public enum MemberStatus { - ACTIVE, INACTIVE, WITHDRAWAL, BLOCKED + ACTIVE, INACTIVE, PENDING, WITHDRAWAL, BLOCKED } diff --git a/src/main/java/com/juu/juulabel/member/domain/Provider.java b/src/main/java/com/juu/juulabel/member/domain/Provider.java index d92b9e19..012de26a 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Provider.java +++ b/src/main/java/com/juu/juulabel/member/domain/Provider.java @@ -2,5 +2,6 @@ public enum Provider { GOOGLE, - KAKAO; + KAKAO, + APPLE; } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/repository/MemberReader.java b/src/main/java/com/juu/juulabel/member/repository/MemberReader.java index 0caabdc9..6b645269 100644 --- a/src/main/java/com/juu/juulabel/member/repository/MemberReader.java +++ b/src/main/java/com/juu/juulabel/member/repository/MemberReader.java @@ -8,6 +8,7 @@ import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; import com.juu.juulabel.member.repository.jpa.MemberQueryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import java.util.List; import java.util.Optional; @@ -24,6 +25,7 @@ public Member getById(final Long id) { .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); } + @Cacheable(value = "member", key = "#email") public Member getByEmail(String email) { return memberJpaRepository.findByEmail(email) .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); diff --git a/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java b/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java index b7554223..b274e3f4 100644 --- a/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java +++ b/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java @@ -1,9 +1,11 @@ package com.juu.juulabel.member.repository.jpa; - import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.domain.MemberStatus; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; @@ -13,7 +15,11 @@ public interface MemberJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long memberId); + @Query("SELECT m FROM Member m WHERE m.email = :email AND m.status != :status") + Optional findByEmailAndStatusNot(String email, MemberStatus status); + boolean existsByEmailAndProvider(String email, Provider provider); boolean existsByNickname(String nickname); + } diff --git a/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java b/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java new file mode 100644 index 00000000..207f8c01 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java @@ -0,0 +1,11 @@ +package com.juu.juulabel.member.request; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e) { + +} diff --git a/src/main/java/com/juu/juulabel/member/request/AppleUser.java b/src/main/java/com/juu/juulabel/member/request/AppleUser.java new file mode 100644 index 00000000..832a1577 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/request/AppleUser.java @@ -0,0 +1,15 @@ +package com.juu.juulabel.member.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.juu.juulabel.member.domain.Provider; + +public record AppleUser( + @JsonProperty("id") String id, + @JsonProperty("email") String email) implements OAuthUser { + + @Override + public Provider provider() { + return Provider.APPLE; + } + +} diff --git a/src/main/java/com/juu/juulabel/member/request/KakaoUser.java b/src/main/java/com/juu/juulabel/member/request/KakaoUser.java index e4f854eb..2862ba54 100644 --- a/src/main/java/com/juu/juulabel/member/request/KakaoUser.java +++ b/src/main/java/com/juu/juulabel/member/request/KakaoUser.java @@ -8,8 +8,7 @@ public record KakaoUser( @JsonProperty("has_signed_up") String hasSignedUp, @JsonProperty("connected_at") String connectedAt, @JsonProperty("synched_at") String synchedAt, - @JsonProperty("kakao_account") KakaoAccount kakaoAccount -) implements OAuthUser { + @JsonProperty("kakao_account") KakaoAccount kakaoAccount) implements OAuthUser { @Override public String email() { return kakaoAccount.email(); diff --git a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java b/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java deleted file mode 100644 index 647ae475..00000000 --- a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.juu.juulabel.member.request; - -import com.juu.juulabel.member.domain.Provider; - -public record OAuthUserInfo( - Long memberId, - String email, - String providerId, - Provider provider -) { -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/token/AppleToken.java b/src/main/java/com/juu/juulabel/member/token/AppleToken.java new file mode 100644 index 00000000..5a2e36da --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/token/AppleToken.java @@ -0,0 +1,21 @@ +package com.juu.juulabel.member.token; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AppleToken( + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("id_token") String idToken) implements OAuthToken { + + @Override + public String scope() { + return null; + } + + @Override + public int refreshTokenExpiresIn() { + return 0; + } +} diff --git a/src/main/java/com/juu/juulabel/member/token/GoogleToken.java b/src/main/java/com/juu/juulabel/member/token/GoogleToken.java index a5342384..414d0d03 100644 --- a/src/main/java/com/juu/juulabel/member/token/GoogleToken.java +++ b/src/main/java/com/juu/juulabel/member/token/GoogleToken.java @@ -3,13 +3,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record GoogleToken( - @JsonProperty("token_type") String tokenType, - @JsonProperty("access_token") String accessToken, - @JsonProperty("expires_in") int expiresIn, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("scope") String scope, - @JsonProperty("id_token") String idToken -) implements OAuthToken { + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken) implements OAuthToken { @Override public int refreshTokenExpiresIn() { return 0; diff --git a/src/main/java/com/juu/juulabel/member/token/KakaoToken.java b/src/main/java/com/juu/juulabel/member/token/KakaoToken.java index 03aac6ae..b6bb322b 100644 --- a/src/main/java/com/juu/juulabel/member/token/KakaoToken.java +++ b/src/main/java/com/juu/juulabel/member/token/KakaoToken.java @@ -3,11 +3,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record KakaoToken( - @JsonProperty("token_type") String tokenType, - @JsonProperty("access_token") String accessToken, - @JsonProperty("expires_in") int expiresIn, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("refresh_token_expires_in") int refreshTokenExpiresIn, - @JsonProperty("scope") String scope -) implements OAuthToken { + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("refresh_token_expires_in") int refreshTokenExpiresIn, + @JsonProperty("scope") String scope) implements OAuthToken { + @Override + public String idToken() { + return null; + } } diff --git a/src/main/java/com/juu/juulabel/member/token/OAuthToken.java b/src/main/java/com/juu/juulabel/member/token/OAuthToken.java index fac5aea9..aa13b4f9 100644 --- a/src/main/java/com/juu/juulabel/member/token/OAuthToken.java +++ b/src/main/java/com/juu/juulabel/member/token/OAuthToken.java @@ -1,6 +1,7 @@ package com.juu.juulabel.member.token; public interface OAuthToken { + String idToken(); String tokenType(); String accessToken(); int expiresIn(); diff --git a/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java b/src/main/java/com/juu/juulabel/member/token/SignUpToken.java similarity index 83% rename from src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java rename to src/main/java/com/juu/juulabel/member/token/SignUpToken.java index 275b5722..3b68310a 100644 --- a/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java +++ b/src/main/java/com/juu/juulabel/member/token/SignUpToken.java @@ -1,4 +1,4 @@ -package com.juu.juulabel.auth.domain; +package com.juu.juulabel.member.token; import com.juu.juulabel.member.domain.Provider; diff --git a/src/main/java/com/juu/juulabel/member/token/Token.java b/src/main/java/com/juu/juulabel/member/token/Token.java deleted file mode 100644 index 5ea3b24b..00000000 --- a/src/main/java/com/juu/juulabel/member/token/Token.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.juu.juulabel.member.token; - -import java.util.Date; - -public record Token( - String accessToken, - Date accessExpiredAt -) { -} diff --git a/src/main/java/com/juu/juulabel/member/token/UserSession.java b/src/main/java/com/juu/juulabel/member/token/UserSession.java new file mode 100644 index 00000000..7e23a654 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/token/UserSession.java @@ -0,0 +1,88 @@ +package com.juu.juulabel.member.token; + +import lombok.*; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberRole; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +// Remove timeToLive from @RedisHash since we'll use @TimeToLive field +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + + @Id + private String id; + + @Indexed + private Long memberId; + + private String email; + + private MemberRole role; + + private String deviceId; + + private String ipAddress; + + private String userAgent; + + private LocalDateTime createdAt; + + private LocalDateTime lastAccessedAt; + + @TimeToLive + private Long ttl; + + /** + * Factory method to create a UserSession from Member data + * This replaces the business logic that was in the constructor + */ + public static UserSession createFromMember(String sessionId, Member member, + String deviceId, String ipAddress, String userAgent) { + final LocalDateTime now = LocalDateTime.now(); + + return UserSession.builder() + .id(sessionId) + .memberId(member.getId()) + .email(member.getEmail()) + .role(member.getRole()) + .deviceId(deviceId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .createdAt(now) + .lastAccessedAt(now) + .ttl((long) AuthConstants.USER_SESSION_TTL) + .build(); + } + + /** + * Creates a copy of this session with updated last accessed time + * This replaces the business logic that was in updateLastAccessed method + */ + public UserSession withUpdatedLastAccessed() { + return UserSession.builder() + .id(this.id) + .memberId(this.memberId) + .email(this.email) + .role(this.role) + .deviceId(this.deviceId) + .ipAddress(this.ipAddress) + .userAgent(this.userAgent) + .createdAt(this.createdAt) + .lastAccessedAt(LocalDateTime.now()) + .ttl((long) AuthConstants.USER_SESSION_TTL) // Reset TTL + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java index 454d68c2..91781fb6 100644 --- a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java +++ b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java @@ -8,6 +8,7 @@ import java.util.List; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.exception.InvalidParamException; @@ -20,120 +21,191 @@ import com.juu.juulabel.terms.request.TermsAgreement; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Map; -import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import java.util.Collections; /** * Member 관련 유틸리티 클래스 + * 회원 가입 시 추가 데이터 처리를 담당 */ +@Slf4j @Component @RequiredArgsConstructor public class MemberUtils { - /** - * 회원-주종 관계 목록 생성 - * - * @param member 회원 - * @param alcoholTypeIdList 주종 ID 목록 - * @param alcoholTypeReader 주종 조회 리포지토리 - * @return 회원-주종 관계 목록 - */ private final TermsReader termsReader; private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; private final AlcoholTypeReader alcoholTypeReader; private final MemberTermsWriter memberTermsWriter; + /** + * 회원 가입 시 추가 데이터 처리 (주종, 약관 동의) + * 트랜잭션 내에서 실행되어야 함 + */ + @Transactional public void processMemberData(Member member, SignUpMemberRequest signUpRequest) { + try { // Process alcohol types if provided - if (signUpRequest.alcoholTypeIds() != null && !signUpRequest.alcoholTypeIds().isEmpty()) { + if (hasAlcoholTypes(signUpRequest)) { processAlcoholTypes(member, signUpRequest); + } // Process terms agreements if provided - if (signUpRequest.termsAgreements() != null && !signUpRequest.termsAgreements().isEmpty()) { + if (hasTermsAgreements(signUpRequest)) { processTermsAgreements(member, signUpRequest); + } + + } catch (InvalidParamException e) { + + throw e; } catch (Exception e) { + throw new InvalidParamException(ErrorCode.INTERNAL_SERVER_ERROR); } } - public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, - AlcoholTypeReader alcoholTypeReader) { - return alcoholTypeIdList.stream() - .map(alcoholTypeId -> { - AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); - return MemberAlcoholType.create(member, alcoholType); - }) + /** + * 회원-주종 관계 처리 (배치 처리 최적화) + */ + private void processAlcoholTypes(Member member, SignUpMemberRequest signUpRequest) { + List alcoholTypeIds = signUpRequest.alcoholTypeIds(); + + // 중복 제거 및 유효성 검증 + List uniqueAlcoholTypeIds = alcoholTypeIds.stream() + .distinct() .toList(); - } - public void processAlcoholTypes(Member member, SignUpMemberRequest signUpRequest) { - List memberAlcoholTypeList = getMemberAlcoholTypeList( - member, signUpRequest.alcoholTypeIds(), alcoholTypeReader); + if (uniqueAlcoholTypeIds.size() != alcoholTypeIds.size()) { + + } + + List memberAlcoholTypeList = createMemberAlcoholTypeList( + member, uniqueAlcoholTypeIds); + if (!memberAlcoholTypeList.isEmpty()) { memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); } } - public void processTermsAgreements(Member member, SignUpMemberRequest signUpRequest) { - List memberTerms = getAndValidateTermsWithMapping(member, - signUpRequest.termsAgreements()); + /** + * 약관 동의 처리 (배치 처리 최적화) + */ + private void processTermsAgreements(Member member, SignUpMemberRequest signUpRequest) { + List memberTerms = validateAndCreateTermsAgreements( + member, signUpRequest.termsAgreements()); + if (!memberTerms.isEmpty()) { memberTermsWriter.storeAll(memberTerms); } } /** - * 약관 동의 정보 검증 및 매핑 생성 + * 회원-주종 관계 목록 생성 (예외 처리 강화) */ - public List getAndValidateTermsWithMapping(Member member, List termsAgreements) { - List usedTermsList = termsReader.getAllByIsUsed(); + private List createMemberAlcoholTypeList(Member member, List alcoholTypeIds) { + return alcoholTypeIds.stream() + .map(alcoholTypeId -> { + try { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + } catch (Exception e) { + throw new InvalidParamException(ErrorCode.ALCOHOL_TYPE_NOT_FOUND); + } + }) + .toList(); + } + + /** + * 약관 동의 정보 검증 및 매핑 생성 (최적화) + */ + private List validateAndCreateTermsAgreements(Member member, List termsAgreements) { + List activeTermsList = termsReader.getAllByIsUsed(); + + if (activeTermsList.isEmpty()) { - if (usedTermsList.isEmpty()) { return Collections.emptyList(); } - validateTermsList(usedTermsList, termsAgreements); - return createMemberTermsList(member, usedTermsList, termsAgreements); + validateTermsAgreements(activeTermsList, termsAgreements); + return createMemberTermsList(member, activeTermsList, termsAgreements); } - public List createMemberTermsList(Member member, List usedTermsList, - List termsAgreements) { - - // 약관 ID를 키로 하는 맵으로 변환하여 조회 성능 개선 + /** + * 약관 동의 검증 (개선된 로직) + */ + private void validateTermsAgreements(List activeTermsList, List termsAgreements) { Map agreementMap = termsAgreements.stream() .collect(Collectors.toMap(TermsAgreement::termsId, Function.identity())); - final LocalDateTime now = LocalDateTime.now(); - List mappings = new ArrayList<>(usedTermsList.size()); + // 모든 활성 약관에 대한 동의가 있는지 확인 + List missingTermsIds = activeTermsList.stream() + .map(Terms::getId) + .filter(termsId -> !agreementMap.containsKey(termsId)) + .toList(); - for (Terms terms : usedTermsList) { - TermsAgreement termsAgreement = Optional.ofNullable(agreementMap.get(terms.getId())) - .orElseThrow(() -> new InvalidParamException(ErrorCode.TERMS_NOT_FOUND)); + if (!missingTermsIds.isEmpty()) { - final boolean isAgreed = termsAgreement.isAgreed(); + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); + } - if (terms.isRequired() && !isAgreed) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); - } + // 필수 약관 동의 확인 + List requiredTermsNotAgreed = activeTermsList.stream() + .filter(Terms::isRequired) + .filter(terms -> { + TermsAgreement agreement = agreementMap.get(terms.getId()); + return agreement == null || !agreement.isAgreed(); + }) + .map(Terms::getId) + .toList(); + + if (!requiredTermsNotAgreed.isEmpty()) { - mappings.add(MemberTerms.create(member, terms, isAgreed, now)); + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); } + } + + /** + * 회원-약관 관계 목록 생성 + */ + private List createMemberTermsList(Member member, List activeTermsList, + List termsAgreements) { + + Map agreementMap = termsAgreements.stream() + .collect(Collectors.toMap(TermsAgreement::termsId, Function.identity())); + + LocalDateTime now = LocalDateTime.now(); - return mappings; + return activeTermsList.stream() + .map(terms -> { + TermsAgreement agreement = agreementMap.get(terms.getId()); + return MemberTerms.create(member, terms, agreement.isAgreed(), now); + }) + .toList(); } - public void validateTermsList(List usedTermsList, List termsAgreements) { - if (usedTermsList.size() != termsAgreements.size()) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); - } + public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, + AlcoholTypeReader alcoholTypeReader) { + return alcoholTypeIdList.stream() + .map(alcoholTypeId -> { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + }) + .toList(); + } + + private boolean hasAlcoholTypes(SignUpMemberRequest signUpRequest) { + return signUpRequest.alcoholTypeIds() != null && !signUpRequest.alcoholTypeIds().isEmpty(); + } + + private boolean hasTermsAgreements(SignUpMemberRequest signUpRequest) { + return signUpRequest.termsAgreements() != null && !signUpRequest.termsAgreements().isEmpty(); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/redis/RedisScriptName.java b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java index 0459f196..90faae03 100644 --- a/src/main/java/com/juu/juulabel/redis/RedisScriptName.java +++ b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java @@ -2,12 +2,6 @@ public enum RedisScriptName { - // Refresh Token - ROTATE_REFRESH_TOKEN("RotateRefreshTokenScriptExecutor"), - LOGIN_REFRESH_TOKEN("LoginRefreshTokenScriptExecutor"), - SAVE_REFRESH_TOKEN("SaveRefreshTokenScriptExecutor"), - REVOKE_REFRESH_TOKEN_BY_INDEX_KEY("RevokeRefreshTokenByIndexKeyExecutor"), - ; private final String executorName; diff --git a/src/main/java/com/juu/juulabel/redis/RedisSessionService.java b/src/main/java/com/juu/juulabel/redis/RedisSessionService.java new file mode 100644 index 00000000..c014614d --- /dev/null +++ b/src/main/java/com/juu/juulabel/redis/RedisSessionService.java @@ -0,0 +1,71 @@ +package com.juu.juulabel.redis; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.auth.repository.UserSessionRepository; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.session.SessionService; +import com.juu.juulabel.member.token.UserSession; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Redis-specific implementation of SessionService + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisSessionService implements SessionService { + + private final UserSessionRepository userSessionRepository; + + @Override + public UserSession createSession(UserSession session) { + try { + return userSessionRepository.save(session); + } catch (Exception e) { + log.error("Failed to create session for member: {}", session.getEmail(), e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public Optional getSession(String sessionId) { + return userSessionRepository.findById(sessionId); + } + + @Override + public UserSession updateSession(UserSession session) { + try { + return userSessionRepository.save(session); + } catch (Exception e) { + log.warn("Failed to update session: {}", session.getId(), e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void deleteSession(String sessionId) { + userSessionRepository.deleteById(sessionId); + } + + @Override + public void deleteAllUserSessions(Long userId) { + try { + userSessionRepository.deleteAllByMemberId(userId); + log.debug("All sessions deleted for user: {}", userId); + } catch (Exception e) { + log.error("Failed to delete all sessions for user: {}", userId, e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public boolean sessionExists(String sessionId) { + return userSessionRepository.existsById(sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/redis/UserSessionManager.java b/src/main/java/com/juu/juulabel/redis/UserSessionManager.java new file mode 100644 index 00000000..0c1d5273 --- /dev/null +++ b/src/main/java/com/juu/juulabel/redis/UserSessionManager.java @@ -0,0 +1,115 @@ +package com.juu.juulabel.redis; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.http.CookieService; +import com.juu.juulabel.common.http.IpAddressService; +import com.juu.juulabel.common.http.RequestDataExtractor; +import com.juu.juulabel.common.session.SessionAuthenticationProvider; +import com.juu.juulabel.common.session.SessionService; +import com.juu.juulabel.common.session.SessionTokenGenerator; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.token.UserSession; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Orchestrates user session workflows using separated services + * Replaces the old SessionManager with better separation of concerns + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSessionManager { + + private final SessionService sessionService; + private final SessionTokenGenerator tokenGenerator; + private final SessionAuthenticationProvider authenticationProvider; + private final CookieService cookieService; + private final RequestDataExtractor requestDataExtractor; + private final IpAddressService ipAddressService; + + /** + * Creates authentication from current session token + */ + public Authentication getAuthentication(String authToken) { + UserSession session = getValidatedSession(authToken); + return authenticationProvider.createAuthentication(session); + } + + /** + * Creates new session for member + */ + public void createSession(Member member) { + // Generate unique session ID + String sessionId = tokenGenerator.generateUniqueToken(sessionService::sessionExists); + + // Create session with current request context + UserSession session = UserSession.createFromMember( + sessionId, + member, + requestDataExtractor.getDeviceId(), + ipAddressService.getClientIpAddress(), + requestDataExtractor.getUserAgent().orElse("unknown")); + + // Save session and set cookie + sessionService.createSession(session); + cookieService.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, AuthConstants.USER_SESSION_TTL); + + log.debug("Session created successfully for member: {}", member.getEmail()); + } + + /** + * Retrieves and validates current session, updating activity + */ + public UserSession getValidatedSession(String authToken) { + UserSession session = sessionService.getSession(authToken) + .orElseThrow(() -> { + log.warn("Session not found for token: {}", maskToken(authToken)); + return new AuthException(ErrorCode.USER_SESSION_EXPIRED); + }); + + // Update session activity (immutable approach) + UserSession updatedSession = session.withUpdatedLastAccessed(); + sessionService.updateSession(updatedSession); + + return updatedSession; + } + + /** + * Invalidates current user session + */ + public void invalidateSession() { + String authToken = cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME) + .orElse(null); + + if (authToken != null) { + sessionService.deleteSession(authToken); + } + cookieService.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + } + + /** + * Invalidates all sessions for a user + */ + public void invalidateAllUserSessions(Long userId) { + sessionService.deleteAllUserSessions(userId); + cookieService.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + log.debug("All sessions invalidated for user: {}", userId); + } + + /** + * Masks sensitive token for logging + */ + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } +} \ No newline at end of file diff --git a/src/main/resources/scripts/login_refresh_token.lua b/src/main/resources/scripts/login_refresh_token.lua deleted file mode 100644 index 6035426b..00000000 --- a/src/main/resources/scripts/login_refresh_token.lua +++ /dev/null @@ -1,44 +0,0 @@ --- KEYS[1] = newTokenKey (e.g., "refresh_token:{hashedToken}") --- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") --- ARGV[1] = memberId --- ARGV[2] = clientId --- ARGV[3] = deviceId --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] - -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Find all keys for the user+client+device -local oldTokenKeys = redis.call("SMEMBERS", indexKey) - --- Revoke all old tokens -for _, key in ipairs(oldTokenKeys) do - if redis.call("EXISTS", key) == 1 then - redis.call("HSET", key, "revoked", 1) - else - redis.call("SREM", indexKey, key) -- clean up dead keys - end -end - --- Save the new token hash -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) - --- Set TTL -redis.call("EXPIRE", newTokenKey, ttl) - --- Update the index with new token -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "LOGIN_SUCCESS" -} diff --git a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua deleted file mode 100644 index beedff35..00000000 --- a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua +++ /dev/null @@ -1,41 +0,0 @@ -local pattern = KEYS[1] - --- Use SCAN instead of KEYS for better performance with large datasets -local cursor = "0" -local batchSize = 100 - -repeat - local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", batchSize) - cursor = result[1] - local indexKeys = result[2] - - for _, idxKey in ipairs(indexKeys) do - local tokenKeys = redis.call("SMEMBERS", idxKey) - - -- Batch revoke tokens to reduce Redis calls - if #tokenKeys > 0 then - for i = 1, #tokenKeys, batchSize do - local batch = {} - local endIdx = math.min(i + batchSize - 1, #tokenKeys) - - for j = i, endIdx do - table.insert(batch, "HSET") - table.insert(batch, tokenKeys[j]) - table.insert(batch, "revoked") - table.insert(batch, 1) - end - - if #batch > 0 then - redis.call(unpack(batch)) - end - end - - -- Clean up the index key - redis.call("DEL", idxKey) - end - end -until cursor == "0" - -return { - ok = "REVOKED_ALL_TOKENS_BY_INDEX_KEY" -} diff --git a/src/main/resources/scripts/rotate_refresh_token.lua b/src/main/resources/scripts/rotate_refresh_token.lua deleted file mode 100644 index 3a470ae9..00000000 --- a/src/main/resources/scripts/rotate_refresh_token.lua +++ /dev/null @@ -1,95 +0,0 @@ --- KEYS[1] = new token key (e.g., "refresh_token:{hashedToken}") --- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") --- KEYS[3] = old token key (e.g., "refresh_token:{hashedToken}") --- ARGV[1] = memberId --- ARGV[2] = clientId --- ARGV[3] = deviceId --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] -local oldTokenKey = KEYS[3] -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Helper function to revoke all member tokens -local function revokeAllMemberTokens(memberId) - local cursor = "0" - local pattern = "refresh_index:" .. memberId .. ":*" - - repeat - local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100) - cursor = result[1] - local indexKeys = result[2] - - for _, idxKey in ipairs(indexKeys) do - local tokenKeys = redis.call("SMEMBERS", idxKey) - - -- Batch revoke tokens (max 100 at a time to avoid large commands) - for i = 1, #tokenKeys, 100 do - local batch = {} - local endIdx = math.min(i + 99, #tokenKeys) - - for j = i, endIdx do - table.insert(batch, "HSET") - table.insert(batch, tokenKeys[j]) - table.insert(batch, "revoked") - table.insert(batch, 1) - end - - if #batch > 0 then - redis.call(unpack(batch)) - end - end - - -- Clean up index - redis.call("DEL", idxKey) - end - until cursor == "0" -end - --- Check if old token exists and get all fields at once -local oldToken = redis.call("HMGET", oldTokenKey, "revoked", "deviceId") -if not oldToken[1] and not oldToken[2] then - return { - err = "OLD_TOKEN_NOT_FOUND" - } -end - --- Check if token is already revoked (using direct array access) -if oldToken[1] == "1" then - revokeAllMemberTokens(memberId) - return { - err = "OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED" - } -end - --- Check device ID mismatch (using direct array access) -if oldToken[2] ~= deviceId then - revokeAllMemberTokens(memberId) - return { - err = "DEVICE_ID_MISMATCH" - } -end - --- Revoke old token -redis.call("HSET", oldTokenKey, "revoked", 1) -redis.call("SREM", indexKey, oldTokenKey) - --- Store new token with all fields at once -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) - --- Set expiration and update index -redis.call("EXPIRE", newTokenKey, ttl) -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "ROTATION_SUCCESS" -} diff --git a/src/main/resources/scripts/save_refresh_token.lua b/src/main/resources/scripts/save_refresh_token.lua deleted file mode 100644 index f800b76a..00000000 --- a/src/main/resources/scripts/save_refresh_token.lua +++ /dev/null @@ -1,31 +0,0 @@ --- KEYS[1] = newTokenKey (e.g., "refresh_token:{hashedToken}") --- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") --- ARGV[1] = memberId --- ARGV[2] = clientId --- ARGV[3] = deviceId --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] - -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Store token --- Save the new token hash -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) -redis.call("EXPIRE", newTokenKey, ttl) - --- Update index with limited size -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "SAVE_REFRESH_TOKEN_SUCCESS" -}