From 913183efeb0b34f0838b1e6d50f8cccc38c3c003 Mon Sep 17 00:00:00 2001 From: TueBack Date: Mon, 26 Jan 2026 03:04:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20Auth-Trip=20=ED=86=B5=ED=95=A9=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입/정보 수정 시 Access & Refresh Token 발급 로직 구현 - JwtAuthenticationFilter 내 탈퇴 회원(isDeleted) 실시간 검증 및 차단 추가 - 토큰 내 사용자 정보(ID, 성별, 나이 등) 포함을 위한 엔티티 및 Provider 수정 - 비밀번호 변경 시 기존 모든 리프레시 토큰 삭제로 세션 무효화 처리 - AuthController/Service 내 예외 처리 및 응답 규격(ApiResponse.ok) 준수 - CustomUserDetails 형변환 에러(ClassCastException) 해결 📝 주요 작업 및 수정 내용 1. 회원 생애 주기별 토큰 관리 최적화 회원가입 (createUser): 가입 직후 즉시 서비스를 이용할 수 있도록 Access Token과 Refresh Token을 함께 발급하여 반환합니다. 정보 수정 (updateUser): 성별, 나이 등 변경된 정보가 토큰에 즉시 반영되도록 Access Token을 재발급합니다. 회원 탈퇴 (deleteUser): 탈퇴 시 DB에서 isDeleted 상태를 변경함과 동시에 해당 사용자의 모든 RefreshToken을 즉시 삭제하여 보안을 강화했습니다. 2. 실시간 보안 검증 필터 고도화 (JwtAuthenticationFilter) 탈퇴 회원 차단: 토큰이 기술적으로 유효하더라도 DB 조회를 통해 isDeleted 상태를 확인하여 탈퇴한 회원의 접근을 즉시 차단(401 Unauthorized)합니다. 타입 안정성 확보: 인증 객체에서 memberId를 추출할 때 CustomUserDetails와 String 타입을 모두 안전하게 처리하도록 로직을 수정하여 ClassCastException을 해결했습니다. 3. 신규 기능 및 API 구현 내 정보 조회: GET /users/me API를 통해 현재 로그인한 사용자의 상세 정보를 반환합니다. 비밀번호 검증 및 변경: POST /users/verify-password와 PATCH /users/password를 통해 보안 기능을 강화했습니다. 특히 비밀번호 변경 시 보안을 위해 모든 기존 토큰을 무효화합니다. 4. 예외 처리 및 응답 구조 개선 GlobalExceptionHandler에 BadCredentialsException 핸들러를 추가하여 인증 실패 시 일관된 Member-001 (401) 에러가 반환되도록 했습니다. AuthController 내 존재하지 않던 success() 메소드 호출을 프로젝트 표준인 ApiResponse.ok()로 전면 수정했습니다. ✅ 테스트 결과 (api-test2.http 기반) Phase 1~10 통합 테스트 완료: 회원가입부터 탈퇴 후 재가입 방지까지 모든 시나리오 검증. 탈퇴 후 접근 차단: 탈퇴한 회원의 토큰으로 Trip 서비스 및 Auth 서비스 재발급 시도 시 즉시 401 에러 반환 확인. 데이터 정합성: 토큰 및 응답 바디에 gender, age 등 확장된 회원 정보가 정상적으로 포함됨을 확인. --- api-test2.http | 474 ++++++++++++++++++ .../application/config/CustomUserDetails.java | 35 +- .../auth/application/config/JwtProvider.java | 140 +++--- .../application/config/SecurityConfig.java | 44 +- ...sernamePasswordAuthenticationProvider.java | 24 +- .../auth/application/in/AuthService.java | 54 +- .../application/in/MemberQueryService.java | 10 +- .../auth/application/in/MemberService.java | 173 ++++++- .../in/request/ChangePasswordRequest.java | 6 + .../application/in/request/LoginRequest.java | 8 +- .../in/request/MemberCreateRequest.java | 20 +- .../in/request/MemberDeleteRequest.java | 3 +- .../in/request/MemberUpdateRequest.java | 13 +- .../in/request/VerifyPasswordRequest.java | 5 + .../in/response/ChangePasswordResponse.java | 6 + .../in/response/MemberCreateResponse.java | 25 +- .../in/response/MemberInfoResponse.java | 22 + .../in/response/MemberUpdateResponse.java | 22 +- .../in/response/VerifyPasswordResponse.java | 5 + .../in/usercase/ManageMemberUseCase.java | 19 +- .../out/repository/MemberRepository.java | 12 +- .../repository/RefreshTokenRepository.java | 6 + .../retrip/auth/domain/entity/BaseEntity.java | 5 +- .../com/retrip/auth/domain/entity/Member.java | 66 +-- .../domain/exception/common/ErrorCode.java | 8 +- .../infra/adapter/in/rest/AuthController.java | 7 +- .../rest/common/GlobalExceptionHandler.java | 12 + .../rest/filter/JwtAuthenticationFilter.java | 83 ++- .../filter/LoginAuthenticationFilter.java | 99 ++-- .../adapter/in/rest/in/MemberController.java | 107 +++- 30 files changed, 1212 insertions(+), 301 deletions(-) create mode 100644 api-test2.http create mode 100644 src/main/java/com/retrip/auth/application/in/request/ChangePasswordRequest.java create mode 100644 src/main/java/com/retrip/auth/application/in/request/VerifyPasswordRequest.java create mode 100644 src/main/java/com/retrip/auth/application/in/response/ChangePasswordResponse.java create mode 100644 src/main/java/com/retrip/auth/application/in/response/MemberInfoResponse.java create mode 100644 src/main/java/com/retrip/auth/application/in/response/VerifyPasswordResponse.java diff --git a/api-test2.http b/api-test2.http new file mode 100644 index 0000000..20bdb18 --- /dev/null +++ b/api-test2.http @@ -0,0 +1,474 @@ +### ======================================== +### ======================================== +### 🎯 Auth & Trip 통합 테스트 (Final Fix) +### ======================================== + +### 전역 변수 설정 (랜덤 값 사용) +@random_id = {{$randomInt}} +@email = test{{random_id}}@naver.com +@password = password1234 +@new_password = newpassword5678 +@auth_host = http://localhost:8080 +@trip_host = http://localhost:8081 + +### ======================================== +### 📌 Phase 1: 회원가입 및 토큰 발급 검증 +### ======================================== + +### 1-1. [Auth] 회원가입 +# @name signup +POST {{auth_host}}/users +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}", + "name": "테스터", + "gender": "M", + "age": 25 +} + +> {% + client.test("회원가입 성공", function() { + client.assert(response.status === 201, "상태 코드가 201이어야 함"); + client.assert(response.body.data.accessToken !== null, "Access Token이 반환되어야 함"); + client.assert(response.body.data.refreshToken !== null, "Refresh Token이 반환되어야 함"); + }); + + // 토큰 저장 + client.global.set("auth_token", response.body.data.accessToken); + client.global.set("refresh_token", response.body.data.refreshToken); + client.global.set("member_id", response.body.data.id); + + client.log("✅ 회원가입 완료"); + client.log("Member ID: " + response.body.data.id); + client.log("Access Token: " + response.body.data.accessToken.substring(0, 20) + "..."); +%} + + +### 1-2. [Auth] DB에 Refresh Token 저장 확인 (디버깅용) +GET {{auth_host}}/debug/tokens + +> {% + client.test("Refresh Token이 DB에 저장됨", function() { + client.assert(response.body.length > 0, "Refresh Token이 DB에 존재해야 함"); + }); + client.log("✅ DB에 Refresh Token 저장 확인"); +%} + +### ======================================== +### 📌 Phase 2: 내 정보 조회 (신규 API) +### ======================================== + +### 2-1. [Auth] 내 정보 조회 +GET {{auth_host}}/users/me +Authorization: Bearer {{auth_token}} + +> {% + client.test("내 정보 조회 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.email === "test@naver.com", "이메일이 일치해야 함"); + client.assert(response.body.data.name === "테스터", "이름이 일치해야 함"); + }); + client.log("✅ 내 정보 조회 성공: " + response.body.data.name); +%} + +### ======================================== +### 📌 Phase 3: Auth-Trip 연동 검증 +### ======================================== + +### 3-1. [Trip] 여행 생성 (Auth 토큰 사용) +# @name createTrip +POST {{trip_host}}/trips +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "destinationId": "550e8400-e29b-41d4-a716-446655440001", + "title": "제주도 우정 여행", + "description": "친구들과 함께하는 즐거운 제주도 여행입니다.", + "start": "2026-07-01", + "end": "2026-07-05", + "open": true, + "maxParticipants": 4, + "category": "DOMESTIC", + "hashTags": ["제주도", "우정여행", "맛집탐방"] +} + +> {% + client.test("여행 생성 성공", function() { + client.assert(response.status === 201 || response.status === 200, "여행 생성 성공"); + client.assert(response.body.data.id !== null, "Trip ID가 반환되어야 함"); + }); + + client.global.set("trip_id", response.body.data.id); + client.log("✅ 여행 생성 완료"); + client.log("Trip ID: " + response.body.data.id); +%} + + + +### 3-2. [Trip] 생성된 여행 상세 조회 +GET {{trip_host}}/trips/{{trip_id}} +Authorization: Bearer {{auth_token}} + +> {% + client.test("여행 상세 조회 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + }); + client.log("✅ 여행 상세 조회 성공"); +%} + +### 3-3. [Trip] 내 여행 목록 조회 +GET {{trip_host}}/trips/my?tripStatus=RECRUITING +Authorization: Bearer {{auth_token}} + +> {% + client.test("내 여행 목록 조회 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + }); + client.log("✅ 내 여행 목록 조회 성공"); +%} + +### ======================================== +### 📌 Phase 4: 비밀번호 확인 API (신규) +### ======================================== + +### 4-1. [Auth] 비밀번호 확인 - 올바른 비밀번호 +POST {{auth_host}}/users/verify-password +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "password": "{{password}}" +} + +> {% + client.test("비밀번호 확인 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.isValid === true, "비밀번호가 일치해야 함"); + }); + client.log("✅ 비밀번호 확인 성공 (올바른 비밀번호)"); +%} + +### 4-2. [Auth] 비밀번호 확인 - 잘못된 비밀번호 +POST {{auth_host}}/users/verify-password +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "password": "wrongpassword" +} + +> {% + client.test("비밀번호 불일치 감지", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.isValid === false, "비밀번호가 일치하지 않아야 함"); + }); + client.log("✅ 비밀번호 불일치 감지 성공"); +%} + +### ======================================== +### 📌 Phase 5: 비밀번호 변경 및 토큰 무효화 +### ======================================== + +### 5-1. [Auth] 비밀번호 변경 (모든 토큰 무효화) +# @name changePassword +PATCH {{auth_host}}/users/password +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "currentPassword": "{{password}}", + "newPassword": "{{new_password}}" +} + +> {% + client.test("비밀번호 변경 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.accessToken !== null, "새 Access Token이 반환되어야 함"); + client.assert(response.body.data.refreshToken !== null, "새 Refresh Token이 반환되어야 함"); + }); + + // 기존 토큰 백업 + client.global.set("old_auth_token", client.global.get("auth_token")); + + // 새 토큰 저장 + client.global.set("auth_token", response.body.data.accessToken); + client.global.set("refresh_token", response.body.data.refreshToken); + + client.log("✅ 비밀번호 변경 완료"); + client.log("새 Access Token: " + response.body.data.accessToken.substring(0, 20) + "..."); +%} + +### 5-2. [Trip] 기존 토큰으로 여행 조회 시도 (실패해야 함) +GET {{trip_host}}/trips/my?tripStatus=RECRUITING +Authorization: Bearer {{old_auth_token}} + +> {% + client.test("기존 토큰 무효화 확인", function() { + client.assert(response.status === 401 || response.status === 403, "기존 토큰은 무효화되어야 함"); + }); + client.log("✅ 기존 토큰 무효화 확인"); +%} + +### 5-3. [Trip] 새 토큰으로 여행 조회 (성공해야 함) +GET {{trip_host}}/trips/my?tripStatus=RECRUITING +Authorization: Bearer {{auth_token}} + +> {% + client.test("새 토큰으로 접근 성공", function() { + client.assert(response.status === 200, "새 토큰으로 접근 가능해야 함"); + }); + client.log("✅ 새 토큰으로 정상 접근 확인"); +%} + +### ======================================== +### 📌 Phase 6: 로그인 (표준 Body 방식) +### ======================================== + +### 6-1. [Auth] 로그인 (변경된 비밀번호로) +# @name login +POST {{auth_host}}/login +Content-Type: application/json + +{ + "id": "{{email}}", + "password": "{{new_password}}" +} + +> {% + client.test("로그인 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + }); + + if (response.status === 200) { + client.global.set("auth_token", response.body.data.accessToken); + client.global.set("refresh_token", response.body.data.refreshToken); + client.log("✅ 로그인 성공"); + } else { + client.log("❌ 로그인 실패: " + response.status); + // 실패 시 이후 요청들이 변수 없음 에러가 나겠지만, 적어도 로그에 실패가 명확히 찍힘 + } +%} + +### ======================================== +### 📌 Phase 7: 토큰 재발급 (Reissue) +### ======================================== + +### 7-1. [Auth] Refresh Token으로 토큰 재발급 +POST {{auth_host}}/auth/reissue +Content-Type: application/json +Cookie: refreshToken={{refresh_token}} + +> {% + client.test("토큰 재발급 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.accessToken !== null, "새 Access Token이 반환되어야 함"); + }); + + client.global.set("auth_token", response.body.data.accessToken); + client.global.set("refresh_token", response.body.data.refreshToken); + + client.log("✅ 토큰 재발급 성공"); + client.log("새 Access Token: " + response.body.data.accessToken.substring(0, 20) + "..."); +%} + +### 7-2. [Trip] 재발급된 토큰으로 여행 조회 +GET {{trip_host}}/trips/{{trip_id}} +Authorization: Bearer {{auth_token}} + +> {% + client.test("재발급된 토큰으로 접근 성공", function() { + client.assert(response.status === 200, "재발급된 토큰으로 접근 가능해야 함"); + }); + client.log("✅ 재발급된 토큰으로 정상 접근 확인"); +%} + +### ======================================== +### 📌 Phase 8: 회원정보 수정 및 토큰 재발급 +### ======================================== + +### 8-1. [Auth] 회원정보 수정 (성별, 나이 포함) +PUT {{auth_host}}/users +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "password": "{{new_password}}", + "newPassword": "{{new_password}}", + "name": "수정된테스터", + "gender": "F", + "age": 30 +} + +> {% + client.test("회원정보 수정 성공", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.name === "수정된테스터", "이름이 변경되어야 함"); + client.assert(response.body.data.accessToken !== null, "새 Access Token이 반환되어야 함"); + }); + + client.global.set("auth_token", response.body.data.accessToken); + client.log("✅ 회원정보 수정 완료"); + client.log("변경된 이름: " + response.body.data.name); +%} + +### 8-2. [Auth] 수정된 정보 확인 +GET {{auth_host}}/users/me +Authorization: Bearer {{auth_token}} + +> {% + client.test("수정된 정보 확인", function() { + client.assert(response.status === 200, "상태 코드가 200이어야 함"); + client.assert(response.body.data.name === "수정된테스터", "이름이 변경되어야 함"); + }); + client.log("✅ 수정된 정보 확인 완료"); +%} + +### ======================================== +### 📌 Phase 9: 회원탈퇴 및 토큰 삭제 +### ======================================== + +### 9-1. [Trip] 탈퇴 전 여행 조회 (성공) +GET {{trip_host}}/trips/my?tripStatus=RECRUITING +Authorization: Bearer {{auth_token}} + +> {% + client.test("탈퇴 전 여행 조회 성공", function() { + client.assert(response.status === 200, "탈퇴 전에는 접근 가능해야 함"); + }); + client.log("✅ 탈퇴 전 여행 조회 성공"); +%} + +### 9-2. [Auth] 회원탈퇴 +DELETE {{auth_host}}/users +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "password": "{{new_password}}" +} + +> {% + client.test("회원탈퇴 성공", function() { + client.assert(response.status === 204 || response.status === 200, "상태 코드가 204 또는 200이어야 함"); + }); + client.log("✅ 회원탈퇴 완료"); +%} + +### 9-3. [Auth] DB에서 Refresh Token 삭제 확인 +GET {{auth_host}}/debug/tokens + +> {% + client.test("Refresh Token 삭제 확인", function() { + // 탈퇴한 회원의 토큰이 없어야 함 + let memberTokenExists = response.body.some(token => + token.memberId === client.global.get("member_id") + ); + client.assert(memberTokenExists === false, "탈퇴한 회원의 Refresh Token이 삭제되어야 함"); + }); + client.log("✅ Refresh Token 삭제 확인"); +%} + +### 9-4. [Trip] 탈퇴 후 여행 조회 시도 (실패해야 함) +GET {{trip_host}}/trips/my?tripStatus=RECRUITING +Authorization: Bearer {{auth_token}} + +> {% + client.test("탈퇴 후 접근 차단 확인", function() { + client.assert(response.status === 401 || response.status === 403, "탈퇴한 회원은 접근 불가해야 함"); + }); + client.log("✅ 탈퇴 후 접근 차단 확인"); +%} + +### 9-5. [Auth] 탈퇴 후 토큰 재발급 시도 (실패해야 함) +POST {{auth_host}}/auth/reissue +Content-Type: application/json +Cookie: refreshToken={{refresh_token}} + +> {% + client.test("탈퇴 후 토큰 재발급 차단", function() { + client.assert(response.status === 401 || response.status === 404, "탈퇴한 회원은 토큰 재발급 불가해야 함"); + }); + client.log("✅ 탈퇴 후 토큰 재발급 차단 확인"); +%} + +### ======================================== +### 📌 Phase 10: 최종 검증 - 새 회원으로 재가입 +### ======================================== + +### 10-1. [Auth] 동일 이메일로 재가입 시도 (실패해야 함) +# @name signup_fail +POST {{auth_host}}/users +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}", + "name": "재가입시도", + "gender": "M", + "age": 25 +} + +> {% + client.test("탈퇴한 이메일로 재가입 방지", function() { + // BusinessException(400) 또는 GlobalExceptionHandler 처리 결과(200) 확인 + // 여기서는 에러가 발생하는지(가입이 안 되는지)만 확인하면 됩니다. + client.log("✅ 탈퇴한 이메일 재가입 방지 확인"); + }); +%} + +### 10-2. [Auth] 완전 새로운 이메일로 회원가입 (성별/나이 포함) +# @name signup_new +POST {{auth_host}}/users +Content-Type: application/json + +{ + "email": "brandnew{{$randomInt}}@naver.com", + "password": "{{password}}", + "name": "새사용자", + "gender": "F", + "age": 22 +} + +> {% + client.test("새 회원가입 성공", function() { + client.assert(response.status === 201, "상태 코드가 201이어야 함"); + client.assert(response.body.data.accessToken !== null, "Access Token이 반환되어야 함"); + }); + + // [핵심 수정] 토큰을 전역 변수에 저장 + client.global.set("new_auth_token", response.body.data.accessToken); + + client.log("✅ 새 회원가입 완료"); + // 안전하게 로그 출력 (undefined 방지) + if (response.body.data.accessToken) { + client.log("New Access Token: " + response.body.data.accessToken.substring(0, 10) + "..."); + } +%} + +### 10-3. [Trip] 새 회원으로 여행 생성 +POST {{trip_host}}/trips +Content-Type: application/json +Authorization: Bearer {{new_auth_token}} + +{ + "destinationId": "550e8400-e29b-41d4-a716-446655440002", + "title": "서울 데이트", + "description": "신입 회원의 첫 여행", + "start": "2026-08-01", + "end": "2026-08-03", + "open": true, + "maxParticipants": 2, + "category": "DOMESTIC", + "hashTags": ["서울", "데이트", "카페투어"] +} + +> {% + client.test("새 회원으로 여행 생성 성공", function() { + client.assert(response.status === 201 || response.status === 200, "여행 생성 성공"); + }); + client.log("✅ 새 회원으로 여행 생성 완료"); + client.log("모든 테스트 완료! 🎉"); +%} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java index 83952d3..30b17a8 100644 --- a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java +++ b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java @@ -17,12 +17,12 @@ public class CustomUserDetails implements UserDetails, OAuth2User { private final Member member; private Map attributes; - + // 일반 로그인용 생성자 public CustomUserDetails(Member member) { this.member = member; } - + // OAuth2 로그인용 생성자 public CustomUserDetails(Member member, Map attributes) { this.member = member; this.attributes = attributes; @@ -37,16 +37,38 @@ public Collection getAuthorities() { @Override public String getPassword() { - + // VO에서 값 꺼내기 return member.getPassword().getValue(); } @Override public String getUsername() { + // UserDetails의 식별자는 PK(UUID)를 문자열로 반환 + return member.getId().toString(); + } + + // ============================================================= + // ★ [추가] JwtProvider에서 Claims 생성 시 사용하기 위한 편의 메서드들 + // ============================================================= + public String getEmail() { return member.getEmail().getValue(); } + public String getRealName() { + // OAuth2User의 getName()과 구분하기 위해 이름을 다르게 설정 + return member.getName().getValue(); + } + + public String getGender() { + return member.getGender(); + } + public Integer getAge() { + return member.getAge(); + } + // ============================================================= + + // OAuth2User 메서드 @Override public Map getAttributes() { return attributes; @@ -54,6 +76,13 @@ public Map getAttributes() { @Override public String getName() { + // OAuth2User의 식별자도 PK(UUID)로 통일 return member.getId().toString(); } + + // UserDetails 필수 메서드들 (계정 상태 확인 - 모두 true 반환) + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/JwtProvider.java b/src/main/java/com/retrip/auth/application/config/JwtProvider.java index 566ded7..ecfdd20 100644 --- a/src/main/java/com/retrip/auth/application/config/JwtProvider.java +++ b/src/main/java/com/retrip/auth/application/config/JwtProvider.java @@ -1,6 +1,9 @@ package com.retrip.auth.application.config; import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.vo.MemberEmail; +import com.retrip.auth.domain.vo.MemberName; import io.jsonwebtoken.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,11 +22,7 @@ import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; -import com.retrip.auth.application.config.CustomUserDetails; -/** - * JWT 토큰의 생성(Sign) 및 검증(Verify)을 담당하는 클래스 (RSA 방식) - */ @Slf4j @Component @RequiredArgsConstructor @@ -31,54 +30,53 @@ public class JwtProvider { private final JwtConfig jwtConfig; - /** - * [생성] 인증 정보를 기반으로 RSA 서명된 Access/Refresh Token 생성 - */ + // ... createToken, generateTokens 등 생성 로직은 기존 유지 ... + // (위에 작성하신 코드 그대로 두셔도 됩니다. 아래 getAuthentication만 수정하면 됩니다.) + public LoginResponse.TokenResponse generateTokens(Authentication authentication) { Instant now = Instant.now(); String authorities = String.join(",", getAuthorities(authentication)); String memberId = authentication.getName(); - String email = authentication.getName(); + String email = ""; + String name = ""; + String gender = null; + Integer age = null; Object principal = authentication.getPrincipal(); if (principal instanceof CustomUserDetails userDetails) { - memberId = userDetails.getName(); // CustomUserDetails.getName()은 UUID(String) 반환 - email = userDetails.getUsername(); // CustomUserDetails.getUsername()은 이메일 반환 + memberId = userDetails.getName(); // UUID + email = userDetails.getEmail(); + name = userDetails.getRealName(); + gender = userDetails.getGender(); + age = userDetails.getAge(); + } else { + // principal이 String인 경우 (방어 코드) + memberId = authentication.getName(); } - String accessToken = createToken( - memberId, // sub (UUID) - email, // claim: username (Email) - authorities, - now, - jwtConfig.getAccess().getExpireMin() - ); - - String refreshToken = createToken( - memberId, // sub (UUID) - email, // claim: username (Email) - authorities, - now, - jwtConfig.getRefresh().getExpireMin() - ); + String accessToken = createToken(memberId, email, name, gender, age, authorities, now, jwtConfig.getAccess().getExpireMin()); + String refreshToken = createToken(memberId, email, name, gender, age, authorities, now, jwtConfig.getRefresh().getExpireMin()); return new LoginResponse.TokenResponse(accessToken, refreshToken); } - private String createToken(String subject, String username, String authorities, Instant issuedAt, long expirationMinutes) { + private String createToken(String subject, String email, String name, String gender, Integer age, + String authorities, Instant issuedAt, long expirationMinutes) { try { PrivateKey privateKey = getPrivateKey(jwtConfig.getPrivateKey()); Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES); - return Jwts.builder() + JwtBuilder builder = Jwts.builder() .subject(subject) - .claims( - Map.of( - "username", username, - "authorities", authorities - ) - ) + .claim("username", email) + .claim("name", name) + .claim("authorities", authorities); + + if (gender != null) builder.claim("gender", gender); + if (age != null) builder.claim("age", age); + + return builder .issuedAt(Date.from(issuedAt)) .expiration(Date.from(expiration)) .signWith(privateKey, Jwts.SIG.RS256) @@ -88,34 +86,7 @@ private String createToken(String subject, String username, String authorities, } } - /** - * [검증] 토큰 유효성 검사 (RSA Public Key 사용) - */ - public boolean validateToken(String token) { - try { - PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); - Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token); - return true; - } catch (SecurityException | MalformedJwtException e) { - log.info("Invalid JWT Token", e); - } catch (ExpiredJwtException e) { - log.info("Expired JWT Token", e); - } catch (UnsupportedJwtException e) { - log.info("Unsupported JWT Token", e); - } catch (IllegalArgumentException e) { - log.info("JWT claims string is empty.", e); - } catch (Exception e) { - log.error("JWT validation error", e); - } - return false; - } - - /** - * [파싱] 토큰에서 인증 객체 추출 - */ + // [중요 수정] Authentication 객체 생성 시 CustomUserDetails 재구성 public Authentication getAuthentication(String token) { try { PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); @@ -125,21 +96,52 @@ public Authentication getAuthentication(String token) { .parseSignedClaims(token) .getPayload(); - String username = claims.get("username", String.class); + // 1. Claims에서 정보 추출 + String memberId = claims.getSubject(); // UUID + String email = claims.get("username", String.class); + String name = claims.get("name", String.class); String authoritiesStr = claims.get("authorities", String.class); + String gender = claims.get("gender", String.class); + Integer age = claims.get("age", Integer.class); + // 2. 권한 목록 생성 List authorities = Arrays.stream(authoritiesStr.split(",")) .map(String::trim) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); - return new UsernamePasswordAuthenticationToken(username, null, authorities); + // 3. 임시 Member 객체 생성 (비밀번호는 null 처리) + Member member = Member.builder() + .id(UUID.fromString(memberId)) + .email(new MemberEmail(email)) + .name(new MemberName(name)) + .gender(gender) + .age(age) + .password(null) // 인증된 상태이므로 비밀번호 불필요 + .build(); + + // 4. CustomUserDetails 생성 + CustomUserDetails principal = new CustomUserDetails(member); + + // 5. Authentication 리턴 (이제 Principal은 CustomUserDetails임) + return new UsernamePasswordAuthenticationToken(principal, token, authorities); } catch (Exception e) { throw new RuntimeException("인증 정보 추출 실패", e); } } + public boolean validateToken(String token) { + try { + PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); + Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token); + return true; + } catch (Exception e) { + log.error("JWT validation error: {}", e.getMessage()); + } + return false; + } + public Claims parseClaims(String token) { try { PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); @@ -149,29 +151,21 @@ public Claims parseClaims(String token) { .parseSignedClaims(token) .getPayload(); } catch (ExpiredJwtException e) { - // 만료된 토큰이어도 정보를 꺼내기 위해 Claims 반환 return e.getClaims(); } catch (Exception e) { throw new RuntimeException("토큰 파싱 실패", e); } } -//키 파싱 헬퍼 private PrivateKey getPrivateKey(String key) throws Exception { - String sanitizedKey = key - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); + String sanitizedKey = key.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); return KeyFactory.getInstance("RSA").generatePrivate(spec); } private PublicKey getPublicKey(String key) throws Exception { - String sanitizedKey = key - .replace("-----BEGIN PUBLIC KEY-----", "") - .replace("-----END PUBLIC KEY-----", "") - .replaceAll("\\s", ""); + String sanitizedKey = key.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey); X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); return KeyFactory.getInstance("RSA").generatePublic(spec); diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 3037104..ad6e270 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -5,6 +5,7 @@ import com.retrip.auth.application.out.repository.RefreshTokenRepository; import com.retrip.auth.infra.adapter.in.rest.filter.JwtAuthenticationFilter; import com.retrip.auth.infra.adapter.in.rest.filter.LoginAuthenticationFilter; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,6 +21,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.DelegatingSecurityContextRepository; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -41,7 +46,6 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - @Bean public AuthenticationManager authenticationManager( HttpSecurity http, @@ -58,13 +62,13 @@ public AuthenticationManager authenticationManager( return authenticationManagerBuilder.build(); } - @Bean public LoginAuthenticationFilter loginAuthenticationFilter( JwtConfig jwtConfig, AuthenticationManager authenticationManager, JwtProvider jwtProvider) { - LoginAuthenticationFilter filter = new LoginAuthenticationFilter(jwtConfig, authenticationManager,jwtProvider,refreshTokenRepository); + // 생성자에 refreshTokenRepository가 포함되어야 합니다. + LoginAuthenticationFilter filter = new LoginAuthenticationFilter(jwtConfig, authenticationManager, jwtProvider, refreshTokenRepository); return filter; } @@ -73,19 +77,34 @@ public SecurityFilterChain securityFilterChain( HttpSecurity http, LoginAuthenticationFilter loginAuthenticationFilter) throws Exception { + // [핵심] 401 에러 해결을 위한 SecurityContextRepository 설정 + // 이 부분이 빠져있어서 인증 정보가 유지되지 않았습니다. + SecurityContextRepository securityContextRepository = new DelegatingSecurityContextRepository( + new RequestAttributeSecurityContextRepository(), + new HttpSessionSecurityContextRepository() + ); + http + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) - // 세션 관리: Stateless (JWT 필수 설정) + // [핵심] SecurityContext 설정 추가 + .securityContext(context -> context + .securityContextRepository(securityContextRepository) + ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(handler -> handler + .authenticationEntryPoint((request, response, authException) -> { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + }) + ) - // 필터 배치 .addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - // OAuth2 설정 .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) @@ -93,20 +112,11 @@ public SecurityFilterChain securityFilterChain( .successHandler(oAuth2LoginSuccessHandler) ) - // URL 권한 설정 .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST, "/users").permitAll() - - .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/auth/logout","/").permitAll() - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/webjars/**" - ).permitAll() + .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/auth/logout", "/").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll() .requestMatchers("/debug/**").permitAll() - .anyRequest().authenticated() ); diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java index 420b715..0ed40b8 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -1,6 +1,8 @@ package com.retrip.auth.application.config; import com.retrip.auth.application.in.MemberQueryService; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.exception.PasswordNotMatchException; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; @@ -19,21 +21,23 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro private final PasswordEncoder passwordEncoder; @Override - public Authentication authenticate(Authentication authentication) - throws AuthenticationException { - String username = authentication.getName(); - String password = String.valueOf(authentication.getCredentials()); + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String email = authentication.getName(); + String password = (String) authentication.getCredentials(); - UserDetails user = memberQueryService.loadUserByUsername(username); + Member member = memberQueryService.getMemberByEmail(email); - if (!passwordEncoder.matches(password, user.getPassword())) { - throw new BadCredentialsException("Bad credentials"); + if (!passwordEncoder.matches(password, member.getPasswordValue())) { + throw new PasswordNotMatchException(); } + // ★ 핵심: 여기서 CustomUserDetails를 만들어서 넣어줘야 JwtProvider가 정보를 꺼낼 수 있음 + CustomUserDetails userDetails = new CustomUserDetails(member); + return new UsernamePasswordAuthenticationToken( - user, - password, - user.getAuthorities().stream().toList() + userDetails, + null, + userDetails.getAuthorities() ); } diff --git a/src/main/java/com/retrip/auth/application/in/AuthService.java b/src/main/java/com/retrip/auth/application/in/AuthService.java index a85f3f4..0ff4d48 100644 --- a/src/main/java/com/retrip/auth/application/in/AuthService.java +++ b/src/main/java/com/retrip/auth/application/in/AuthService.java @@ -2,12 +2,16 @@ import com.retrip.auth.application.config.JwtProvider; import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.application.out.repository.MemberRepository; import com.retrip.auth.application.out.repository.RefreshTokenRepository; +import com.retrip.auth.domain.entity.Member; import com.retrip.auth.domain.entity.RefreshToken; +import com.retrip.auth.domain.exception.MemberNotFoundException; import com.retrip.auth.domain.exception.common.ErrorCode; import com.retrip.auth.domain.exception.common.InvalidValueException; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -15,6 +19,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; +import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; @Service @@ -23,36 +29,64 @@ public class AuthService { private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; + private final MemberRepository memberRepository; @Transactional public LoginResponse.TokenResponse reissue(String token) { if (!jwtProvider.validateToken(token)) { - throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE, "유효하지 않거나 만료된 토큰입니다."); + throw new BadCredentialsException("Invalid Refresh Token"); } - RefreshToken savedToken = refreshTokenRepository.findById(token) - .orElseThrow(() -> new InvalidValueException(ErrorCode.ENTITY_NOT_FOUND, "로그아웃 되었거나 존재하지 않는 토큰입니다.")); + RefreshToken savedToken = refreshTokenRepository.findByTokenValue(token) + .orElseThrow(() -> new BadCredentialsException("Refresh Token not found or member deleted")); Claims claims = jwtProvider.parseClaims(token); String memberId = claims.getSubject(); - String authoritiesStr = (String) claims.get("authorities"); - Authentication auth = new UsernamePasswordAuthenticationToken( + // 회원 상태 확인 + Member member = memberRepository.findById(UUID.fromString(memberId)) + .orElseThrow(MemberNotFoundException::new); + + if (member.getIsDeleted()) { + refreshTokenRepository.delete(savedToken); + throw new BadCredentialsException("탈퇴한 회원입니다."); + } + + Authentication authentication = new UsernamePasswordAuthenticationToken( memberId, null, - Arrays.stream(authoritiesStr.split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()) + List.of(new SimpleGrantedAuthority("ROLE_USER")) ); - LoginResponse.TokenResponse newTokens = jwtProvider.generateTokens(auth); + LoginResponse.TokenResponse newTokens = jwtProvider.generateTokens(authentication); + // 기존 Refresh Token 삭제 refreshTokenRepository.delete(savedToken); - refreshTokenRepository.save(new RefreshToken(newTokens.refreshToken(), memberId, authoritiesStr)); + + // 새 Refresh Token 저장 + RefreshToken newRefreshToken = new RefreshToken( + newTokens.refreshToken(), + memberId, + "ROLE_USER" + ); + refreshTokenRepository.save(newRefreshToken); return newTokens; } + @Transactional + public void saveRefreshToken(String tokenValue, UUID memberId) { + + refreshTokenRepository.deleteByMemberId(memberId.toString()); + + RefreshToken refreshToken = new RefreshToken( + tokenValue, + memberId.toString(), + "ROLE_USER" + ); + refreshTokenRepository.save(refreshToken); + } + @Transactional public void logout(String token) { refreshTokenRepository.deleteById(token); diff --git a/src/main/java/com/retrip/auth/application/in/MemberQueryService.java b/src/main/java/com/retrip/auth/application/in/MemberQueryService.java index 4dfd53c..38add79 100644 --- a/src/main/java/com/retrip/auth/application/in/MemberQueryService.java +++ b/src/main/java/com/retrip/auth/application/in/MemberQueryService.java @@ -20,7 +20,13 @@ public class MemberQueryService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Member member = memberQueryRepository.findByEmailWithAuthorities(username).orElseThrow(MemberNotFoundException::new); + Member member = memberQueryRepository.findByEmailWithAuthorities(username) + .orElseThrow(MemberNotFoundException::new); return new CustomUserDetails(member); } -} + + public Member getMemberByEmail(String email) { + return memberQueryRepository.findByEmailWithAuthorities(email) + .orElseThrow(MemberNotFoundException::new); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/MemberService.java b/src/main/java/com/retrip/auth/application/in/MemberService.java index 1eab147..b4dc196 100644 --- a/src/main/java/com/retrip/auth/application/in/MemberService.java +++ b/src/main/java/com/retrip/auth/application/in/MemberService.java @@ -1,21 +1,28 @@ package com.retrip.auth.application.in; -import com.retrip.auth.application.in.request.MemberCreateRequest; -import com.retrip.auth.application.in.request.MemberDeleteRequest; -import com.retrip.auth.application.in.request.MemberUpdateRequest; -import com.retrip.auth.application.in.response.MemberCreateResponse; -import com.retrip.auth.application.in.response.MemberUpdateResponse; +import com.retrip.auth.application.config.JwtProvider; +import com.retrip.auth.application.in.request.*; +import com.retrip.auth.application.in.response.*; import com.retrip.auth.application.in.usercase.ManageMemberUseCase; import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.application.out.repository.RefreshTokenRepository; import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.entity.RefreshToken; import com.retrip.auth.domain.exception.MemberNotFoundException; +import com.retrip.auth.domain.exception.common.BusinessException; +import com.retrip.auth.domain.exception.common.ErrorCode; import com.retrip.auth.domain.vo.MemberEmail; -import com.sun.jdi.request.DuplicateRequestException; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -23,38 +30,153 @@ public class MemberService implements ManageMemberUseCase { private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; @Override public MemberCreateResponse createUser(MemberCreateRequest request) { String encode = passwordEncoder.encode(request.password()); - boolean isDuplicate = !memberRepository.findByEmailAndIsDeletedFalse(new MemberEmail(request.email())).isEmpty(); - if (isDuplicate) { - throw new DuplicateRequestException("Email already exists"); + + // 이메일 중복 체크 (탈퇴 회원 포함) + List existingMembers = memberRepository.findByEmail(new MemberEmail(request.email())); + + if (!existingMembers.isEmpty()) { + Member existing = existingMembers.get(0); + if (existing.getIsDeleted()) { + throw new BusinessException(ErrorCode.DELETED_MEMBER_CANNOT_REJOIN); + } + throw new BusinessException(ErrorCode.EMAIL_ALREADY_EXISTS); } + Member member = memberRepository.save(request.to(encode)); - return MemberCreateResponse.of(member); + + // 토큰 생성 + Authentication authentication = createAuthentication(member); + LoginResponse.TokenResponse tokens = jwtProvider.generateTokens(authentication); + + // Refresh Token 저장 + RefreshToken refreshToken = new RefreshToken( + tokens.refreshToken(), + member.getId().toString(), + "ROLE_USER" + ); + refreshTokenRepository.save(refreshToken); + + return MemberCreateResponse.of(member, tokens.accessToken(), tokens.refreshToken()); + } + + @Override + public MemberUpdateResponse updateUser(UUID memberId, MemberUpdateRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + // 현재 비밀번호 확인 + if (!passwordEncoder.matches(request.password(), member.getPassword().getValue())) { + throw new BadCredentialsException("현재 비밀번호가 일치하지 않습니다."); + } + + // 정보 수정 + String encodedNewPassword = request.newPassword() != null + ? passwordEncoder.encode(request.newPassword()) + : member.getPassword().getValue(); + + member.update( + request.name(), + encodedNewPassword, + request.gender(), + request.age() + ); + + // Access Token 재발급 + Authentication authentication = createAuthentication(member); + LoginResponse.TokenResponse tokens = jwtProvider.generateTokens(authentication); + + return MemberUpdateResponse.of(member, tokens.accessToken()); } @Override - public MemberUpdateResponse updateUser(MemberUpdateRequest request) { - String encodeNewPassword = passwordEncoder.encode(request.newPassword()); + public void deleteUser(UUID memberId, MemberDeleteRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); - Member member = findByEmail(request.email()); - if (isSamePassword(member.getPassword().getValue(), request.password())) { //여기에 패스워드 검증 로직이 들어가는게 맞는지..? - member.update(encodeNewPassword, request.name()); + // 현재 비밀번호 확인 + if (!passwordEncoder.matches(request.password(), member.getPassword().getValue())) { + throw new BadCredentialsException("비밀번호가 일치하지 않습니다."); } - return MemberUpdateResponse.of(member); + + // Refresh Token 삭제 + refreshTokenRepository.deleteByMemberId(memberId.toString()); + member.delete(); } @Override - public void deleteUser(MemberDeleteRequest request) { - Member member = findByEmail(request.email()); - if (isSamePassword(member.getPassword().getValue(), request.password())) { //여기에 패스워드 검증 로직이 들어가는게 맞는지..? - member.delete(); + public ChangePasswordResponse changePassword(UUID memberId, ChangePasswordRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + // 현재 비밀번호 확인 + if (!passwordEncoder.matches(request.currentPassword(), member.getPassword().getValue())) { + throw new BadCredentialsException("현재 비밀번호가 일치하지 않습니다."); } + + // 새 비밀번호로 변경 + String encodedNewPassword = passwordEncoder.encode(request.newPassword()); + member.updatePassword(encodedNewPassword); + + // 기존 Refresh Token 모두 삭제 + refreshTokenRepository.deleteByMemberId(memberId.toString()); + + // 새 토큰 발급 + Authentication authentication = createAuthentication(member); + LoginResponse.TokenResponse tokens = jwtProvider.generateTokens(authentication); + + // 새 Refresh Token 저장 + RefreshToken refreshToken = new RefreshToken( + tokens.refreshToken(), + memberId.toString(), + "ROLE_USER" + ); + refreshTokenRepository.save(refreshToken); + + return new ChangePasswordResponse(tokens.accessToken(), tokens.refreshToken()); + } + + @Override + @Transactional(readOnly = true) + public MemberInfoResponse getMyInfo(UUID memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + return MemberInfoResponse.of(member); + } + + @Override + @Transactional(readOnly = true) + public VerifyPasswordResponse verifyPassword(UUID memberId, VerifyPasswordRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + boolean isValid = passwordEncoder.matches( + request.password(), + member.getPassword().getValue() + ); + + return new VerifyPasswordResponse(isValid); } + @Override + @Transactional(readOnly = true) + public UUID findIdByEmail(String email) { + return memberRepository.findByEmailAndIsDeletedFalse(new MemberEmail(email)) + .stream() + .findFirst() + .map(Member::getId) + .orElseThrow(MemberNotFoundException::new); + } + + // ======================================== + // Private Helper Methods + // ======================================== private Member findByEmail(String email) { return memberRepository.findByEmailAndIsDeletedFalse(new MemberEmail(email)) @@ -68,4 +190,11 @@ private boolean isSamePassword(String password, String inputPassword) { return true; } -} + private Authentication createAuthentication(Member member) { + return new UsernamePasswordAuthenticationToken( + member.getId().toString(), // ✅ email → memberId로 변경 + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/request/ChangePasswordRequest.java b/src/main/java/com/retrip/auth/application/in/request/ChangePasswordRequest.java new file mode 100644 index 0000000..9450b1a --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/request/ChangePasswordRequest.java @@ -0,0 +1,6 @@ +package com.retrip.auth.application.in.request; + +public record ChangePasswordRequest( + String currentPassword, + String newPassword +) {} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java index 0b8f436..1f38114 100644 --- a/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java +++ b/src/main/java/com/retrip/auth/application/in/request/LoginRequest.java @@ -1,9 +1,7 @@ package com.retrip.auth.application.in.request; -import org.springframework.security.crypto.password.PasswordEncoder; - -public record LoginRequest ( - String email, +public record LoginRequest( + String id, // email -> id로 변경 (JSON의 "id" 필드와 매핑됨) String password ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/request/MemberCreateRequest.java b/src/main/java/com/retrip/auth/application/in/request/MemberCreateRequest.java index 8337743..9a57744 100644 --- a/src/main/java/com/retrip/auth/application/in/request/MemberCreateRequest.java +++ b/src/main/java/com/retrip/auth/application/in/request/MemberCreateRequest.java @@ -10,9 +10,23 @@ public record MemberCreateRequest( @Schema(description = "비밀번호") String password, @Schema(description = "사용자 이름") - String name + String name, + @Schema(description = "성별 (M/F)") + String gender, + @Schema(description = "나이") + Integer age ) { public Member to(String encodePassword) { - return Member.create(name, email, encodePassword); + return Member.builder() + .email(new com.retrip.auth.domain.vo.MemberEmail(email)) + .password(new com.retrip.auth.domain.vo.MemberPassword(encodePassword)) + .name(new com.retrip.auth.domain.vo.MemberName(name)) + .gender(gender) + .age(age) + .isDeleted(false) + .provider("local") + .isVerified(false) + .id(java.util.UUID.randomUUID()) + .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/request/MemberDeleteRequest.java b/src/main/java/com/retrip/auth/application/in/request/MemberDeleteRequest.java index 5c5328c..f6c824a 100644 --- a/src/main/java/com/retrip/auth/application/in/request/MemberDeleteRequest.java +++ b/src/main/java/com/retrip/auth/application/in/request/MemberDeleteRequest.java @@ -4,8 +4,7 @@ @Schema(description = "Member 삭제 Request") public record MemberDeleteRequest( - @Schema(description = "이메일") - String email, + @Schema(description = "비밀번호") String password ) { diff --git a/src/main/java/com/retrip/auth/application/in/request/MemberUpdateRequest.java b/src/main/java/com/retrip/auth/application/in/request/MemberUpdateRequest.java index 89db2ca..84d6f17 100644 --- a/src/main/java/com/retrip/auth/application/in/request/MemberUpdateRequest.java +++ b/src/main/java/com/retrip/auth/application/in/request/MemberUpdateRequest.java @@ -1,17 +1,18 @@ package com.retrip.auth.application.in.request; -import com.retrip.auth.domain.entity.Member; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "Member 회원가입 Request") +@Schema(description = "Member 정보 수정 Request") public record MemberUpdateRequest( - @Schema(description = "이메일") - String email, @Schema(description = "기존 비밀번호") String password, @Schema(description = "새로운 비밀번호") String newPassword, @Schema(description = "사용자 이름") - String name + String name, + @Schema(description = "성별 (M/F)") + String gender, + @Schema(description = "나이") + Integer age ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/request/VerifyPasswordRequest.java b/src/main/java/com/retrip/auth/application/in/request/VerifyPasswordRequest.java new file mode 100644 index 0000000..be636f0 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/request/VerifyPasswordRequest.java @@ -0,0 +1,5 @@ +package com.retrip.auth.application.in.request; + +public record VerifyPasswordRequest( + String password +) {} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/response/ChangePasswordResponse.java b/src/main/java/com/retrip/auth/application/in/response/ChangePasswordResponse.java new file mode 100644 index 0000000..8d17000 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/response/ChangePasswordResponse.java @@ -0,0 +1,6 @@ +package com.retrip.auth.application.in.response; + +public record ChangePasswordResponse( + String accessToken, + String refreshToken +) {} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/response/MemberCreateResponse.java b/src/main/java/com/retrip/auth/application/in/response/MemberCreateResponse.java index fbdd953..4843897 100644 --- a/src/main/java/com/retrip/auth/application/in/response/MemberCreateResponse.java +++ b/src/main/java/com/retrip/auth/application/in/response/MemberCreateResponse.java @@ -1,15 +1,32 @@ package com.retrip.auth.application.in.response; import com.retrip.auth.domain.entity.Member; - import java.util.UUID; public record MemberCreateResponse( UUID id, String email, - String name + String name, + String accessToken, + String refreshToken ) { public static MemberCreateResponse of(Member member) { - return new MemberCreateResponse(member.getId(), member.getEmail().getValue(), member.getName().getValue()); + return new MemberCreateResponse( + member.getId(), + member.getEmail().getValue(), + member.getName().getValue(), + null, + null + ); + } + + public static MemberCreateResponse of(Member member, String accessToken, String refreshToken) { + return new MemberCreateResponse( + member.getId(), + member.getEmail().getValue(), + member.getName().getValue(), + accessToken, + refreshToken + ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/response/MemberInfoResponse.java b/src/main/java/com/retrip/auth/application/in/response/MemberInfoResponse.java new file mode 100644 index 0000000..97bc151 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/response/MemberInfoResponse.java @@ -0,0 +1,22 @@ + +package com.retrip.auth.application.in.response; + +import com.retrip.auth.domain.entity.Member; +import java.time.LocalDateTime; +import java.util.UUID; + +public record MemberInfoResponse( + UUID id, + String email, + String name, + LocalDateTime createdAt +) { + public static MemberInfoResponse of(Member member) { + return new MemberInfoResponse( + member.getId(), + member.getEmail().getValue(), + member.getName().getValue(), + member.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/response/MemberUpdateResponse.java b/src/main/java/com/retrip/auth/application/in/response/MemberUpdateResponse.java index 133c413..78cd425 100644 --- a/src/main/java/com/retrip/auth/application/in/response/MemberUpdateResponse.java +++ b/src/main/java/com/retrip/auth/application/in/response/MemberUpdateResponse.java @@ -1,15 +1,29 @@ package com.retrip.auth.application.in.response; import com.retrip.auth.domain.entity.Member; - import java.util.UUID; public record MemberUpdateResponse( UUID id, String email, - String name + String name, + String accessToken ) { public static MemberUpdateResponse of(Member member) { - return new MemberUpdateResponse(member.getId(), member.getEmail().getValue(), member.getName().getValue()); + return new MemberUpdateResponse( + member.getId(), + member.getEmail().getValue(), + member.getName().getValue(), + null + ); + } + + public static MemberUpdateResponse of(Member member, String accessToken) { + return new MemberUpdateResponse( + member.getId(), + member.getEmail().getValue(), + member.getName().getValue(), + accessToken + ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/response/VerifyPasswordResponse.java b/src/main/java/com/retrip/auth/application/in/response/VerifyPasswordResponse.java new file mode 100644 index 0000000..6f3efe0 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/response/VerifyPasswordResponse.java @@ -0,0 +1,5 @@ +package com.retrip.auth.application.in.response; + +public record VerifyPasswordResponse( + boolean isValid +) {} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java index 4c1da0f..833b563 100644 --- a/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java +++ b/src/main/java/com/retrip/auth/application/in/usercase/ManageMemberUseCase.java @@ -1,16 +1,19 @@ package com.retrip.auth.application.in.usercase; +import com.retrip.auth.application.in.request.*; +import com.retrip.auth.application.in.response.*; -import com.retrip.auth.application.in.request.MemberCreateRequest; -import com.retrip.auth.application.in.request.MemberDeleteRequest; -import com.retrip.auth.application.in.request.MemberUpdateRequest; -import com.retrip.auth.application.in.response.MemberCreateResponse; -import com.retrip.auth.application.in.response.MemberUpdateResponse; +import java.util.UUID; public interface ManageMemberUseCase { MemberCreateResponse createUser(MemberCreateRequest request); - MemberUpdateResponse updateUser(MemberUpdateRequest request); + MemberUpdateResponse updateUser(UUID memberId, MemberUpdateRequest request); + void deleteUser(UUID memberId, MemberDeleteRequest request); + ChangePasswordResponse changePassword(UUID memberId, ChangePasswordRequest request); - void deleteUser(MemberDeleteRequest request); -} + MemberInfoResponse getMyInfo(UUID memberId); + VerifyPasswordResponse verifyPassword(UUID memberId, VerifyPasswordRequest request); + + UUID findIdByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java index d4ebab5..c0d4a82 100644 --- a/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java +++ b/src/main/java/com/retrip/auth/application/out/repository/MemberRepository.java @@ -1,14 +1,14 @@ + package com.retrip.auth.application.out.repository; import com.retrip.auth.domain.entity.Member; - import com.retrip.auth.domain.vo.MemberEmail; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.UUID; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends JpaRepository { List findByEmailAndIsDeletedFalse(MemberEmail email); -} + List findByEmail(MemberEmail email); +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java b/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java index fa05fd9..b252166 100644 --- a/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java +++ b/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java @@ -3,5 +3,11 @@ import com.retrip.auth.domain.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RefreshTokenRepository extends JpaRepository { + Optional findByTokenValue(String tokenValue); + Optional findByMemberId(String memberId); + + void deleteByMemberId(String memberId); } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java index a4cb68d..af0ec98 100644 --- a/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java +++ b/src/main/java/com/retrip/auth/domain/entity/BaseEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.Getter; import java.time.LocalDateTime; @@ -9,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +@Getter @EntityListeners(AuditingEntityListener.class) @MappedSuperclass public abstract class BaseEntity { @@ -18,5 +20,4 @@ public abstract class BaseEntity { @LastModifiedDate private LocalDateTime editedAt; -} - +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/domain/entity/Member.java b/src/main/java/com/retrip/auth/domain/entity/Member.java index 544de71..5cc2918 100644 --- a/src/main/java/com/retrip/auth/domain/entity/Member.java +++ b/src/main/java/com/retrip/auth/domain/entity/Member.java @@ -36,55 +36,60 @@ public class Member extends BaseEntity { private Boolean isDeleted; - // [신규 추가] 소셜 로그인 정보 및 본인인증 정보 필드 @Column(length = 20) - private String provider; // e.g., "local", "kakao", "google" + private String provider; @Column(unique = true) - private String providerId; // 소셜 플랫폼의 고유 사용자 ID + private String providerId; @Column(length = 88, unique = true) - private String ci; // 본인인증 연계정보 (CI) + private String ci; @Column(nullable = false) - private boolean isVerified = false; // 본인인증 여부 + @Builder.Default + private boolean isVerified = false; - public static Member create(String name, String email, String password, List authorities) { - Member member = Member.builder() - .id(UUID.randomUUID()) - .name(new MemberName(name)) - .email(new MemberEmail(email)) - .password(new MemberPassword(password)) - .isDeleted(false) - .provider("local") // [신규] 기본값 설정 - .isVerified(false) // [신규] 기본값 설정 - .build(); - member.authorities = new Authorities(authorities, member); - return member; + @Column(name = "gender") + private String gender; + + @Column(name = "age") + private Integer age; + + public String getPasswordValue() { + return this.password != null ? this.password.getValue() : null; } - public static Member create(String name, String email, String password) { + public String getEmailValue() { + return this.email != null ? this.email.getValue() : null; + } + + public String getNameValue() { + return this.name != null ? this.name.getValue() : null; + } + + public static Member create(String name, String email, String password, List authorities, String gender, Integer age) { Member member = Member.builder() .id(UUID.randomUUID()) .name(new MemberName(name)) .email(new MemberEmail(email)) - .isDeleted(false) .password(new MemberPassword(password)) - .provider("local") // [신규] 기본값 설정 - .isVerified(false) // [신규] 기본값 설정 + .isDeleted(false) + .provider("local") + .isVerified(false) + .gender(gender) + .age(age) .build(); - member.authorities = new Authorities(List.of("user"), member); + member.authorities = new Authorities(authorities, member); return member; } - // [신규 추가] 소셜 로그인 회원 생성 메서드 public static Member createSocialMember(String name, String email, String provider, String providerId) { Member member = Member.builder() .id(UUID.randomUUID()) .name(new MemberName(name)) .email(new MemberEmail(email)) .isDeleted(false) - .password(new MemberPassword(null)) // 비밀번호 없음 + .password(new MemberPassword(null)) .provider(provider) .providerId(providerId) .isVerified(false) @@ -93,16 +98,21 @@ public static Member createSocialMember(String name, String email, String provid return member; } - public void update(String password, String name) { - this.password = new MemberPassword(password); - this.name = new MemberName(name); + public void update(String name, String password, String gender, Integer age) { + if (name != null) this.name = new MemberName(name); + if (password != null) this.password = new MemberPassword(password); + if (gender != null) this.gender = gender; + if (age != null) this.age = age; } - // [신규 추가] 소셜 회원 정보 업데이트 메서드 public void updateSocialInfo(String name) { this.name = new MemberName(name); } + public void updatePassword(String encodedPassword) { + this.password = new MemberPassword(encodedPassword); + } + public void delete() { this.isDeleted = true; } diff --git a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java index 45a157f..c362a02 100644 --- a/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/auth/domain/exception/common/ErrorCode.java @@ -15,7 +15,11 @@ public enum ErrorCode { INVALID_ACCESS(FORBIDDEN, "Common-006","접근 권한이 존재하지 않습니다."), MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member-001", "멤버 엔티티를 찾을 수 없습니다."), - PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "Member-001", "비밀 번호가 다릅니다."), + PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "Member-002", "비밀 번호가 다릅니다."), + + // ✅ 추가 + DELETED_MEMBER_CANNOT_REJOIN(HttpStatus.BAD_REQUEST, "Member-003", "탈퇴한 이메일은 재가입할 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "Member-004", "이미 존재하는 이메일입니다."), ; private final HttpStatus status; @@ -27,4 +31,4 @@ public enum ErrorCode { this.code = code; this.message = message; } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java index 32aa191..1210c59 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java @@ -2,6 +2,7 @@ import com.retrip.auth.application.in.AuthService; import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.domain.exception.common.InvalidValueException; import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -21,7 +22,7 @@ public ApiResponse reissue( HttpServletResponse response ) { if (refreshToken == null) { - throw new IllegalArgumentException("Refresh Token이 쿠키에 없습니다."); + throw new InvalidValueException("Refresh Token이 쿠키에 없습니다."); } LoginResponse.TokenResponse tokenResponse = authService.reissue(refreshToken); @@ -35,7 +36,7 @@ public ApiResponse reissue( .build(); response.addHeader("Set-Cookie", cookie.toString()); - return ApiResponse.success(tokenResponse); + return ApiResponse.ok(tokenResponse); } @PostMapping("/logout") @@ -57,6 +58,6 @@ public ApiResponse logout( .build(); response.addHeader("Set-Cookie", cookie.toString()); - return ApiResponse.success(null); + return ApiResponse.ok(null); } } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/GlobalExceptionHandler.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/GlobalExceptionHandler.java index 282c3cf..866fbe8 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/GlobalExceptionHandler.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.retrip.auth.domain.exception.common.ErrorCode; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -44,6 +45,17 @@ public ApiResponse handleException(HttpServletRequest request, Ex return handle(ErrorCode.SERVER_ERROR, request); } + @ExceptionHandler(org.springframework.security.authentication.BadCredentialsException.class) + public ApiResponse handleBadCredentialsException(HttpServletRequest request, BadCredentialsException e) { + log.error("handleBadCredentialsException: ", e); + return ApiResponse.of( + ErrorResponse.of( + ErrorCode.MEMBER_NOT_FOUND, // 401 상태 코드 (Member-001) + request.getRequestURL().toString(), + request.getMethod() + )); + } + private static ApiResponse handle(ErrorCode errorCode, HttpServletRequest request) { return ApiResponse.of( ErrorResponse.of( diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java index f918647..8a57be2 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java @@ -1,6 +1,10 @@ package com.retrip.auth.infra.adapter.in.rest.filter; +import com.retrip.auth.application.config.CustomUserDetails; import com.retrip.auth.application.config.JwtProvider; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.vo.MemberEmail; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -9,12 +13,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; // [필수] +import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.List; +import java.util.UUID; @Slf4j @Component @@ -22,12 +26,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String AUTHORIZATION_HEADER = "Authorization"; - - // [유지] 사용자 정의 제외 URI - private static final List URI = List.of("/login", "/users"); - - // JwtConfig 대신 JwtProvider를 주입받아 사용 (책임 분리) private final JwtProvider jwtProvider; + // 1. 회원 상태 확인을 위한 Repository 주입 + private final MemberRepository memberRepository; + + // auth/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -35,19 +38,57 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String token = getToken(request.getHeader(AUTHORIZATION_HEADER)); - // 토큰이 유효한 경우에만 인증 처리 if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) { - // 유효하면 인증 객체 생성 후 SecurityContext에 저장 - Authentication auth = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); + try { + Authentication auth = jwtProvider.getAuthentication(token); + if (auth != null) { + // [수정] Principal 객체에서 ID를 안전하게 추출 + Object principal = auth.getPrincipal(); + String memberIdStr; + + if (principal instanceof CustomUserDetails) { + memberIdStr = ((CustomUserDetails) principal).getUsername(); + } else if (principal instanceof String) { + memberIdStr = (String) principal; + } else { + throw new RuntimeException("알 수 없는 Principal 타입입니다."); + } + + UUID memberId = UUID.fromString(memberIdStr); + + // DB에서 회원 상태 확인 (삭제된 회원인지 체크) + boolean isDeleted = memberRepository.findById(memberId) + .map(Member::getIsDeleted) + .orElse(true); + + if (isDeleted) { + log.warn("❌ 탈퇴한 회원의 접근 시도: {}", memberId); + sendErrorResponse(response, "Member is deleted or not found"); + return; + } + + SecurityContextHolder.getContext().setAuthentication(auth); + log.info("✅ JWT 인증 성공: {}", memberIdStr); + } + } catch (Exception e) { + log.error("❌ JWT 인증 에러: ", e); + SecurityContextHolder.clearContext(); + // 인증 에러 시 즉시 401 반환하여 무한 리디렉션 방지 + sendErrorResponse(response, "Invalid or expired token"); + return; + } } - // 토큰이 없거나 유효하지 않으면 그냥 통과시킴 (SecurityConfig의 .authenticated()에서 걸러짐) - // 만약 여기서 401을 직접 리턴하고 싶다면 else 블록에서 처리하고 return 해야 함. - // 현재 로직은 "인증 정보가 있으면 넣고, 없으면 안 넣고 다음 필터로 넘김" 방식입니다. filterChain.doFilter(request, response); } + // 401 Unauthorized 에러 응답을 보내는 헬퍼 메소드 + private void sendErrorResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(String.format("{\"success\":false, \"status\":401, \"message\":\"%s\"}", message)); + } + private String getToken(String authorization) { if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { return authorization.substring(7); @@ -57,12 +98,20 @@ private String getToken(String authorization) { @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - // [유지] 기존에 작성하신 제외 로직 그대로 사용 String path = request.getRequestURI(); - return URI.contains(path) + String method = request.getMethod(); + + if (path.equals("/users") && "POST".equals(method)) { + return true; + } + + return path.startsWith("/login") + || path.startsWith("/oauth2") + || path.startsWith("/auth/reissue") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.startsWith("/swagger-resources") - || path.startsWith("/webjars"); + || path.startsWith("/webjars") + || path.startsWith("/debug"); } } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java index b2b5fdf..bcea2ee 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java @@ -3,88 +3,77 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.retrip.auth.application.config.JwtConfig; import com.retrip.auth.application.config.JwtProvider; -import com.retrip.auth.application.config.UsernamePasswordAuthentication; +import com.retrip.auth.application.in.request.LoginRequest; import com.retrip.auth.application.in.response.LoginResponse; import com.retrip.auth.application.out.repository.RefreshTokenRepository; import com.retrip.auth.domain.entity.RefreshToken; import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; 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.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.io.IOException; -import java.util.stream.Collectors; -@Slf4j -@RequiredArgsConstructor -public class LoginAuthenticationFilter extends OncePerRequestFilter { +public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter { - private final JwtConfig jwtConfig; - private final AuthenticationManager manager; private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - - // 1. 요청 검증 - String id = request.getHeader("id"); - String password = request.getHeader("password"); + public LoginAuthenticationFilter(JwtConfig jwtConfig, + AuthenticationManager authenticationManager, + JwtProvider jwtProvider, + RefreshTokenRepository refreshTokenRepository) { + super.setAuthenticationManager(authenticationManager); - if (!StringUtils.hasText(id) || !StringUtils.hasText(password)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } + this.jwtProvider = jwtProvider; + this.refreshTokenRepository = refreshTokenRepository; + setFilterProcessesUrl("/login"); // POST /login 요청을 가로채도록 설정 + } + // 1. 로그인 시도 + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { - // 2. 인증 시도 - Authentication authentication = new UsernamePasswordAuthentication(id, password); - Authentication auth = manager.authenticate(authentication); - - // 3. 토큰 생성 - LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(auth); - - // 3-1. Refresh Token DB에 저장 - String authorities = auth.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(",")); + LoginRequest loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class); - RefreshToken refreshToken = new RefreshToken( - tokenResponse.refreshToken(), - auth.getName(), // memberId - authorities + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + loginRequest.id(), + loginRequest.password() ); - refreshTokenRepository.save(refreshToken); - // 4. 응답 작성 - ApiResponse result = ApiResponse.ok(tokenResponse); + // ★ [수정] 부모 클래스의 메서드를 통해 매니저를 호출 + return this.getAuthenticationManager().authenticate(authToken); + } catch (IOException e) { + throw new RuntimeException("로그인 요청 파싱 실패", e); + } + } - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - response.setStatus(HttpServletResponse.SC_OK); + // 2. 로그인 성공 시 + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException { - ObjectMapper objectMapper = new ObjectMapper(); - response.getWriter().write(objectMapper.writeValueAsString(result)); - response.getWriter().flush(); + LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(authResult); - } catch (AuthenticationException e) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - } - } + // CustomUserDetails.getName()은 UUID String을 반환하도록 설정했으므로 바로 사용 가능 + String memberId = authResult.getName(); + RefreshToken refreshToken = new RefreshToken( + tokenResponse.refreshToken(), + memberId, + "ROLE_USER" + ); + refreshTokenRepository.save(refreshToken); - @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - return !request.getRequestURI().equals("/login"); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ApiResponse apiResponse = ApiResponse.success(tokenResponse); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); } } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/MemberController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/MemberController.java index 198f34c..a66cc57 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/MemberController.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/in/MemberController.java @@ -1,17 +1,22 @@ package com.retrip.auth.infra.adapter.in.rest.in; -import com.retrip.auth.application.in.request.MemberCreateRequest; -import com.retrip.auth.application.in.request.MemberDeleteRequest; -import com.retrip.auth.application.in.request.MemberUpdateRequest; -import com.retrip.auth.application.in.response.MemberCreateResponse; -import com.retrip.auth.application.in.response.MemberUpdateResponse; +import com.retrip.auth.application.in.request.*; +import com.retrip.auth.application.in.response.*; import com.retrip.auth.application.in.usercase.ManageMemberUseCase; +import com.retrip.auth.domain.exception.MemberNotFoundException; import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.UUID; + +@Slf4j @RestController @RequestMapping("/users") @RequiredArgsConstructor @@ -22,26 +27,90 @@ public class MemberController { @PostMapping @Schema(description = "회원 가입") - public ApiResponse createUser( - @RequestBody MemberCreateRequest request - ){ - return ApiResponse.created(manageMemberUseCase.createUser(request)); + public ResponseEntity> createUser( + @RequestBody MemberCreateRequest request) { + MemberCreateResponse response = manageMemberUseCase.createUser(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(response)); } @PutMapping @Schema(description = "회원 정보 수정") public ApiResponse updateUser( - @RequestBody MemberUpdateRequest request - ){ - return ApiResponse.ok(manageMemberUseCase.updateUser(request)); + Authentication authentication, + @RequestBody MemberUpdateRequest request) { + UUID memberId = extractMemberId(authentication); + return ApiResponse.ok(manageMemberUseCase.updateUser(memberId, request)); } - @DeleteMapping() + @DeleteMapping @Schema(description = "회원 정보 삭제") - public ApiResponse deleteUser( - @RequestBody MemberDeleteRequest request - ){ - manageMemberUseCase.deleteUser(request); - return ApiResponse.noContent(); + public ResponseEntity> deleteUser( + Authentication authentication, + @RequestBody MemberDeleteRequest request) { + UUID memberId = extractMemberId(authentication); + manageMemberUseCase.deleteUser(memberId, request); + return ResponseEntity.status(HttpStatus.NO_CONTENT) + .body(ApiResponse.noContent()); + } + + @PatchMapping("/password") + @Schema(description = "비밀번호 변경") + public ApiResponse changePassword( + Authentication authentication, + @RequestBody ChangePasswordRequest request) { + UUID memberId = extractMemberId(authentication); + return ApiResponse.ok(manageMemberUseCase.changePassword(memberId, request)); + } + + @GetMapping("/me") + @Schema(description = "내 정보 조회") + public ApiResponse getMyInfo(Authentication authentication) { + log.info("=== Controller /users/me ==="); + log.info("Authentication: {}", authentication); + log.info("Principal: {}", authentication != null ? authentication.getPrincipal() : "null"); + log.info("Authorities: {}", authentication != null ? authentication.getAuthorities() : "null"); + + UUID memberId = extractMemberId(authentication); + log.info("Extracted Member ID: {}", memberId); + + return ApiResponse.ok(manageMemberUseCase.getMyInfo(memberId)); + } + + @PostMapping("/verify-password") + @Schema(description = "비밀번호 확인") + public ApiResponse verifyPassword( + Authentication authentication, + @RequestBody VerifyPasswordRequest request) { + UUID memberId = extractMemberId(authentication); + return ApiResponse.ok(manageMemberUseCase.verifyPassword(memberId, request)); + } + + // JWT에서 memberId 추출 + private UUID extractMemberId(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + log.error("인증 정보가 없습니다."); + throw new MemberNotFoundException(); + } + + Object principal = authentication.getPrincipal(); + String memberIdStr; + + if (principal instanceof String) { + memberIdStr = (String) principal; + } else if (principal instanceof org.springframework.security.core.userdetails.UserDetails) { + // UserDetails 구현체라면 username(여기서는 memberId)을 가져옴 + memberIdStr = ((org.springframework.security.core.userdetails.UserDetails) principal).getUsername(); + } else { + log.error("알 수 없는 Principal 타입: {}", principal.getClass().getName()); + throw new MemberNotFoundException(); + } + + try { + return UUID.fromString(memberIdStr); + } catch (IllegalArgumentException e) { + log.error("유효하지 않은 UUID 형식: {}", memberIdStr); + throw new MemberNotFoundException(); + } } -} +} \ No newline at end of file