diff --git a/Chanyeol/build.gradle b/Chanyeol/build.gradle index f763c095..e6552ff6 100644 --- a/Chanyeol/build.gradle +++ b/Chanyeol/build.gradle @@ -37,6 +37,15 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/Chanyeol/keyword-summary/ch08.md b/Chanyeol/keyword-summary/ch08.md deleted file mode 100644 index 282a2ebf..00000000 --- a/Chanyeol/keyword-summary/ch08.md +++ /dev/null @@ -1,66 +0,0 @@ -- Spring Security가 무엇인가? - - ## 스프링 기반의 애플리케이션의 보안을 담당하는 프레임워크 - - Spring Security는 필터 기반으로 동작한다. - - ❗️필터: 요청이 Dispatcher Servlet으로 가기 전에 위치해있으며, 클라이언트와 자원 사이에서 요청과 응답 정보를 이용해 다양한 처리를 하는 컴포넌트 - - ### 동작흐름 - - HTTP 요청 - ↓ - Filter Chain (여러 보안 필터 순차 실행) - ↓ - 인증 처리 (AuthenticationManager) - ↓ - 인가 처리 (AccessDecisionManager) - ↓ - 컨트롤러 도달 (or 403/401 반환) - - Spring Security는 `SecurityFilterChain`을 통해 요청마다 보안 처리를 수행한다. - - ![SecurityFilterChain](attachment:f16ac6dc-fafe-4c80-b041-8b6b695c9047:스크린샷_2026-05-18_오후_1.41.47.png) - - SecurityFilterChain - - ![Spring Security 인증 처리 과정](attachment:27a6f073-a478-47f1-8f2c-50703ef6fd13:스크린샷_2026-05-18_오후_1.43.36.png) - - Spring Security 인증 처리 과정 - - ### 주요 특징 - - | 특징 | 설명 | - | --- | --- | - | **다양한 인증 방식** | Form 로그인, HTTP Basic, JWT, OAuth2/OIDC, SAML 등 | - | **CSRF 보호** | Cross-Site Request Forgery 공격 방어 | - | **세션 관리** | 세션 고정 공격 방지, 동시 세션 제어 | - | **비밀번호 암호화** | BCrypt, Argon2 등 강력한 해시 알고리즘 | - | **Spring 통합** | Spring MVC, Spring Boot와 완벽하게 통합 | - - ### 자주 사용되는 시나리오 - - - **JWT 기반 인증** — REST API + 모바일/SPA 앱 - - **OAuth2 소셜 로그인** — 카카오, 구글, 네이버 로그인 - - **세션 기반 인증** — 전통적인 MVC 웹 앱 - - **메서드 레벨 보안** — `@PreAuthorize`, `@Secured` 어노테이션 -- 인증(Authentication)vs 인가(Authorization) - - ![스크린샷 2026-05-18 오후 1.55.14.png](attachment:bb447443-1268-43fd-8edb-041dc7f1dfe0:스크린샷_2026-05-18_오후_1.55.14.png) - - - 인증(Authentication) : 본인이누구인지확인 (로그인) - - 승인(Authorization) : 특정 리소스에권한이 있는지확인 (등급 권한) - - | | 인증(Authentication) | 인가(Authorization) | - | --- | --- | --- | - | 기능 | 자격 증명 확인 | 권한 허기/거부 | - | 진행 방식 | 비밀번호, 생체인식, 일회용 핀 또는 앱 | 보안 팀에서 관리하는 설정 사용 | - | 사용자 확인 가능 여부 | 가능 | 불가능 | - | 사용자 변경 가능 여부 | 부분적 가능 | 불가능 | - | 데이터 전송 | ID 토큰 사용 | 액세스 토큰 사용 | -- Stateful vs Stateless - - Stateful: ****서버가 클라이언트 상태를 **기억** - - Stateless**:** 서버가 클라이언트 상태를 **기억하지 않음** - - Stateful → 서버가 기억 → 세션/쿠키 → 전통적 웹 - Stateless → 서버가 무상태 → JWT 토큰 → REST API / 모바일 \ No newline at end of file diff --git a/Chanyeol/keyword-summary/ch09.md b/Chanyeol/keyword-summary/ch09.md new file mode 100644 index 00000000..d3e2078c --- /dev/null +++ b/Chanyeol/keyword-summary/ch09.md @@ -0,0 +1,142 @@ +- 세션과 토큰의 차이는? + + ## **Session (세션)** + + 세션은 클라이언트의 인증 정보를 **서버 측에 저장하고 관리**하는 방식입니다. + + 사용자가 로그인할 때, 서버는 사용자의 아이디, 권한, 만료 시간 등의 정보를 서버의 메모리, 데이터베이스, 또는 Redis 같은 캐시 저장소에 보관합니다. 그리고 이 정보를 식별할 수 있는 **고유한 Session ID**만 클라이언트(브라우저)에게 쿠키 형태로 전달합니다. + + 클라이언트는 이후 요청마다 이 Session ID를 자동으로 서버에 보내고, 서버는 해당 ID를 통해 저장된 사용자 정보를 찾아 인증을 처리합니다. 즉, **서버가 클라이언트의 상태를 직접 관리**하기 때문에 **Stateful(상태 유지)** 방식이라고 합니다. + + ### 장점 + + - 민감한 정보(사용자 권한, 개인정보 등)를 클라이언트가 아닌 서버에 보관하여 비교적 안전하다. + - 세션을 서버에서 직접 삭제하면 **즉시 로그아웃** 처리(강제 로그아웃)가 가능하다. + - 토큰에 비해 데이터 크기가 작아 네트워크 부하가 적다. + + ### 단점 + + - 사용자가 증가하면 서버가 관리해야 할 세션 데이터가 많아져 **메모리 및 DB 부하**가 커진다. + - 서버를 여러 대로 확장(Scale-out)할 때, 세션 공유 문제가 발생한다. (세션 클러스터링, Sticky Session, Redis 등의 중앙 저장소 필요) + - 서버가 다운되면 모든 세션이 사라질 위험이 있다. + + --- + + ## **Token (토큰)** + + 토큰은 인증에 성공한 클라이언트에게 서버가 발급해주는 **디지털 서명된 문자열**입니다. + + 대표적으로 **JWT(JSON Web Token)**가 많이 사용되며, 이 토큰 안에 사용자 ID, 권한, 만료시간 등의 정보를 직접 포함하고 있습니다. 클라이언트는 이 토큰을 안전한 곳(보통 localStorage 또는 HttpOnly 쿠키)에 저장했다가, 이후 API 요청 시 Authorization: Bearer 헤더에 담아 서버로 보냅니다. + + 서버는 토큰의 **서명을 검증**하기만 하면 되며, 별도로 사용자 정보를 DB에서 조회할 필요가 없습니다. 즉, **서버가 클라이언트의 상태를 유지하지 않는 Stateless(무상태)** 방식입니다. + + ### 장점 + + - 서버가 세션 정보를 저장하지 않아 **서버 부하가 적고**, 확장성(Scale-out)이 뛰어나다. + - 마이크로 서비스, 모바일 앱, 외부 API 연동에 매우 적합하다. + - 토큰 자체에 필요한 정보를 모두 담고 있어 DB 조회 횟수가 줄어든다. + + ### 단점 + + - 토큰의 크기가 세션 ID보다 훨씬 커서, 요청이 많아질수록 **네트워크 트래픽이 증가**할 수 있다. + - 토큰이 탈취당하면 만료시간까지 유효하기 때문에 대처가 어렵다. (Refresh Token을 함께 사용하는 이유) + - 토큰을 무효화(강제 로그아웃)하기가 세션에 비해 복잡하다. (블랙리스트, 짧은 만료시간 + Refresh Token 조합 등으로 보완) +- 엑세스 토큰과 리프레시 토큰이란? + + ## **Access Token (엑세스 토큰)** + + Access Token은 클라이언트가 실제로 서버의 보호된 리소스(예: 사용자 정보 조회, 글 작성 등)에 접근할 때 사용하는 **짧은 수명의 토큰**입니다. + + 주로 JWT 형식으로 발급되며, 사용자 ID, 권한(Role), 만료시간(expiration) 등의 정보를 포함하고 있습니다. 클라이언트는 이 토큰을 Authorization: Bearer 헤더에 담아 API 요청을 보냅니다. 서버는 토큰의 서명만 검증하면 인증이 완료됩니다. + + 일반적으로 **15분 ~ 1시간** 정도의 매우 짧은 유효 기간을 가집니다. + + ### 장점 + + - 유효 기간이 짧아 토큰이 탈취당하더라도 피해를 최소화할 수 있다. + - 서버가 별도의 세션 저장소를 필요로 하지 않아 **확장성**이 뛰어나다. + - 요청마다 빠르게 인증 처리 가능 (Stateless) + + ### 단점 + + - 자주 만료되므로, 만료될 때마다 다시 로그인해야 하는 문제가 발생할 수 있다. + - 만료 시마다 새로운 Access Token을 발급받아야 하므로, Refresh Token과 함께 사용해야 한다. + - 토큰 크기가 커서 네트워크 트래픽에 약간의 영향을 줄 수 있다. + + --- + + ## **Refresh Token (리프레시 토큰)** + + Refresh Token은 **Access Token이 만료되었을 때**, 새로운 Access Token을 발급받기 위해 사용하는 **긴 수명의 토큰**입니다. + + Access Token과 달리 사용자 정보를 많이 포함하지 않고, 주로 토큰 식별자나 사용자 ID 정도만 담습니다. 보안을 위해 보통 **HttpOnly 쿠키**에 저장하며, JavaScript에서 접근할 수 없도록 처리합니다. + + 유효 기간은 보통 **7일 ~ 30일** 정도로 Access Token보다 훨씬 길게 설정합니다. Refresh Token을 이용해 Access Token을 갱신하는 과정을 **Token Refresh** 또는 **Silent Refresh**라고 합니다. + + ### 장점 + + - 사용자가 매번 재로그인하지 않고도 오랜 기간 로그인 상태를 유지할 수 있다. + - Access Token의 수명을 짧게 유지하면서도 사용자 편의성을 높일 수 있다. + - Refresh Token을 서버에서 관리(블랙리스트, Rotation)하면 보안성을 크게 강화할 수 있다. + + ### 단점 + + - 유효 기간이 길어 탈취당할 경우 피해가 크다. + - Refresh Token 자체를 안전하게 보관해야 하며, 탈취 방지를 위한 추가적인 보안 조치가 필요하다. + - 서버에서 Refresh Token을 무효화하거나 관리하는 로직이 추가로 필요하다. + + --- + + ### **현대적인 추천 방식 (Access + Refresh Token 조합)** + + 1. 로그인 성공 시 → **Access Token + Refresh Token** 동시 발급 + 2. 클라이언트는 Access Token으로 API 호출 + 3. Access Token 만료 → Refresh Token으로 새로운 Access Token 요청 + 4. Refresh Token도 만료되면 → 다시 로그인 유도 + + 이 방식은 **세션의 편의성**과 **토큰의 확장성**을 동시에 잡을 수 있는 현재 가장 많이 사용되는 아키텍처입니다. + +- OAuth 1.0과 OAuth 2.0의 차이는? + + ## **OAuth 1.0** + + OAuth 1.0은 2007년에 발표된 최초의 OAuth 버전으로, **외부 애플리케이션이 사용자 대신 서버의 리소스에 안전하게 접근**할 수 있도록 설계된 인증 프로토콜입니다. + + 사용자가 로그인하면 **Request Token** → **Access Token**으로 교환하는 복잡한 과정을 거칩니다. 가장 큰 특징은 **디지털 서명(Signature)**을 사용한다는 점입니다. 매 요청마다 HMAC-SHA1 등의 알고리즘으로 서명을 생성하여 전송합니다. + + ### 장점 + + - HTTPS를 필수로 요구하지 않아도 어느 정도 보안이 가능하다 (서명 기반). + - 서명이 포함되어 있어 토큰이 탈취당하더라도 요청 내용을 위변조하기 어렵다. + - 비교적 엄격한 보안 모델을 제공한다. + + ### 단점 + + - 구현이 매우 복잡하고 어렵다 (서명 생성, nonce, timestamp 등). + - 모바일 앱이나 JavaScript 환경에서 사용하기 불편하다. + - 토큰 만료와 갱신이 제대로 지원되지 않아 보안 및 사용자 경험이 떨어진다. + - 현재는 거의 사용되지 않을 정도로 구식이다. + + --- + + ## **OAuth 2.0** + + OAuth 2.0은 2012년에 발표된 OAuth 1.0의 후속 버전으로, **현대 웹과 모바일 환경에 최적화**된 인증 프레임워크입니다. + + OAuth 1.0의 복잡성을 크게 개선하여 **Bearer Token** 방식을 주로 사용합니다. 다양한 인증 방식(Grant Type)을 지원하며, Access Token + Refresh Token 조합을 표준적으로 활용합니다. + + 대표적인 Grant Type으로는 Authorization Code, Implicit, Client Credentials, Password, Refresh Token 등이 있다. + + ### 장점 + + - 구현이 OAuth 1.0에 비해 훨씬 간단하고 직관적이다. + - HTTPS를 기반으로 하여 보안성을 강화하면서도 개발 편의성이 높다. + - 다양한 클라이언트 환경(웹, 모바일, 서버 간 통신)에 적합한 Grant Type을 제공한다. + - Access Token의 짧은 수명 + Refresh Token으로 보안과 편의성을 동시에 달성할 수 있다. + - 현재 대부분의 서비스(Google, Kakao, Naver, GitHub 등)가 OAuth 2.0을 사용한다. + + ### 단점 + + - Bearer Token을 사용하므로 토큰이 탈취당하면 즉시 악용될 위험이 있다 (HTTPS 필수). + - OAuth 1.0보다 서버 측에서 상태 관리를 더 신경 써야 하는 경우가 있다 (Refresh Token 관리). + - 잘못 구현하면 보안 취약점이 발생하기 쉽다 (예: Implicit Grant의 보안 문제). \ No newline at end of file diff --git "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.41.47.png" "b/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.41.47.png" deleted file mode 100644 index c45ab4c4..00000000 Binary files "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.41.47.png" and /dev/null differ diff --git "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.43.36.png" "b/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.43.36.png" deleted file mode 100644 index e910d6fe..00000000 Binary files "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.43.36.png" and /dev/null differ diff --git "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.55.14.png" "b/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.55.14.png" deleted file mode 100644 index 03bcf83e..00000000 Binary files "a/Chanyeol/keyword-summary/\354\212\244\355\201\254\353\246\260\354\203\267 2026-05-18 \354\230\244\355\233\204 1.55.14.png" and /dev/null differ diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/ask/controller/AskController.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/ask/controller/AskController.java index 15b3020c..b34b15ed 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/ask/controller/AskController.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/ask/controller/AskController.java @@ -5,10 +5,11 @@ import com.example.umc10thweek4.domain.ask.exception.code.AskSuccessCode; import com.example.umc10thweek4.domain.ask.service.AskService; import com.example.umc10thweek4.global.apiPayload.ApiResponse; -import com.example.umc10thweek4.global.security.util.SecurityUtil; +import com.example.umc10thweek4.global.security.entity.AuthMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -24,8 +25,11 @@ public class AskController { * 문의 등록 */ @PostMapping("/v1/asks") - public ResponseEntity> createAsk(@RequestBody @Valid AskReqDTO.Create request) { - Long currentMemberId = SecurityUtil.getCurrentMemberId(); + public ResponseEntity> createAsk( + @AuthenticationPrincipal AuthMember authMember, + @RequestBody @Valid AskReqDTO.Create request + ) { + Long currentMemberId = authMember.getMember().getId(); AskResDTO.Create response = askService.createAsk(currentMemberId, request); return ApiResponse.onSuccessResponse(AskSuccessCode.CREATE_SUCCESS, response); @@ -34,6 +38,15 @@ public ResponseEntity> createAsk(@RequestBody @Val /** * 내가 작성한 문의 목록 */ + @GetMapping("/v1/users/me/asks") + public ResponseEntity>> getMyAsks( + @AuthenticationPrincipal AuthMember authMember + ) { + Long currentMemberId = authMember.getMember().getId(); + List response = askService.getMyAsks(currentMemberId); + return ApiResponse.onSuccessResponse(AskSuccessCode.LIST_SUCCESS, response); + } + @GetMapping("/v1/users/{userId}/asks") public ResponseEntity>> getMyAsks(@PathVariable Long userId) { List response = askService.getMyAsks(userId); diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/controller/MemberController.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/controller/MemberController.java index f807819e..5e787ea2 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/controller/MemberController.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/controller/MemberController.java @@ -4,8 +4,10 @@ import com.example.umc10thweek4.domain.member.exception.code.MemberSuccessCode; import com.example.umc10thweek4.domain.member.service.MemberService; import com.example.umc10thweek4.global.apiPayload.ApiResponse; +import com.example.umc10thweek4.global.security.entity.AuthMember; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -15,6 +17,14 @@ public class MemberController { private final MemberService memberService; + @GetMapping("/v1/users/me") + public ResponseEntity> getMyPage( + @AuthenticationPrincipal AuthMember authMember + ) { + Long currentMemberId = authMember.getMember().getId(); + return ApiResponse.onSuccessResponse(MemberSuccessCode.MY_PAGE_SUCCESS, memberService.getMyPage(currentMemberId)); + } + @GetMapping("/v1/users/{userId}") public ResponseEntity> getMyPage(@PathVariable Long userId) { return ApiResponse.onSuccessResponse(MemberSuccessCode.MY_PAGE_SUCCESS, memberService.getMyPage(userId)); diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/converter/MemberConverter.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/converter/MemberConverter.java index 67904b06..a0ad75e0 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/converter/MemberConverter.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/converter/MemberConverter.java @@ -3,6 +3,10 @@ import com.example.umc10thweek4.domain.member.dto.MemberResDTO; import com.example.umc10thweek4.domain.member.entity.Member; import com.example.umc10thweek4.domain.member.entity.mapping.MemberNoticeSetting; +import com.example.umc10thweek4.domain.member.enums.Gender; +import com.example.umc10thweek4.global.security.dto.OAuthDTO; + +import java.time.LocalDate; public class MemberConverter { @@ -27,4 +31,17 @@ public static MemberResDTO.GetInfo toGetInfoRes(Member member, MemberNoticeSetti )) .build(); } -} \ No newline at end of file + + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.nickname()) + .nickname(dto.socialType().name().toLowerCase() + "_" + dto.socialUid()) + .email(dto.email()) + .password("") + .birth(LocalDate.of(1900, 1, 1)) + .gender(Gender.NONE) + .socialUid(dto.socialUid()) + .socialType(dto.socialType()) + .build(); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/exception/code/MemberErrorCode.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/exception/code/MemberErrorCode.java index 97346a12..29ec7b8a 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/exception/code/MemberErrorCode.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/exception/code/MemberErrorCode.java @@ -14,9 +14,12 @@ public enum MemberErrorCode implements BaseErrorCode { DUPLICATE_EMAIL(HttpStatus.CONFLICT, "MEMBER409_1", "이미 사용 중인 이메일입니다."), DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "MEMBER409_2", "이미 사용 중인 닉네임입니다."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "존재하지 않는 회원입니다."), - NOTICE_SETTING_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_2", "알림 설정 정보가 없습니다."); + NOTICE_SETTING_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_2", "알림 설정 정보가 없습니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, "MEMBER400_3", "지원하지 않는 소셜 로그인 제공자입니다."), + OAUTH_EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_4", "소셜 계정에서 이메일 정보를 가져올 수 없습니다."), + OAUTH_REQUIRED_ATTRIBUTE_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_5", "소셜 계정에서 필수 정보를 가져올 수 없습니다."); private final HttpStatus status; private final String code; private final String message; -} \ No newline at end of file +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/repository/MemberRepository.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/repository/MemberRepository.java index 5c506f9d..0bf7b447 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/repository/MemberRepository.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package com.example.umc10thweek4.domain.member.repository; import com.example.umc10thweek4.domain.member.entity.Member; +import com.example.umc10thweek4.domain.member.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,7 +17,9 @@ public interface MemberRepository extends JpaRepository { @Query("SELECT m FROM Member m WHERE m.email = :email AND m.deletedAt IS NULL") Optional findActiveByEmail(@Param("email") String email); + Optional findBySocialTypeAndSocialUidAndDeletedAtIsNull(SocialType socialType, String socialUid); + boolean existsByEmail(String email); boolean existsByNickname(String nickname); -} \ No newline at end of file +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/mission/controller/MissionController.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/mission/controller/MissionController.java index 7f94ebc3..21621bb8 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/mission/controller/MissionController.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/mission/controller/MissionController.java @@ -6,9 +6,11 @@ import com.example.umc10thweek4.domain.mission.service.MissionService; import com.example.umc10thweek4.global.apiPayload.ApiResponse; import com.example.umc10thweek4.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10thweek4.global.security.entity.AuthMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -29,6 +31,29 @@ public ResponseEntity> getHome( missionService.getHomeMissions(regionId, memberId)); } + @GetMapping("/v1/home/me") + public ResponseEntity> getMyHome( + @AuthenticationPrincipal AuthMember authMember, + @RequestParam(required = false) Long regionId + ) { + Long currentMemberId = authMember.getMember().getId(); + return ApiResponse.onSuccessResponse(MissionSuccessCode.HOME_SUCCESS, + missionService.getHomeMissions(regionId, currentMemberId)); + } + + @GetMapping("/v1/users/me/missions") + public ResponseEntity>> getMyMissions( + @AuthenticationPrincipal AuthMember authMember, + @RequestParam(defaultValue = "0") Integer pageNumber, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String sort) { + + Long currentMemberId = authMember.getMember().getId(); + + return ApiResponse.onSuccessResponse(MissionSuccessCode.MY_MISSION_SUCCESS, + missionService.getMyMissions(currentMemberId, pageSize, pageNumber, sort)); + } + @GetMapping("/v1/users/{userId}/missions") public ResponseEntity>> getMyMissions( @PathVariable Long userId, diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/review/controller/ReviewController.java b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/review/controller/ReviewController.java index 703725e6..88707031 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/domain/review/controller/ReviewController.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/domain/review/controller/ReviewController.java @@ -5,10 +5,11 @@ import com.example.umc10thweek4.domain.review.exception.code.ReviewSuccessCode; import com.example.umc10thweek4.domain.review.service.ReviewService; import com.example.umc10thweek4.global.apiPayload.ApiResponse; -import com.example.umc10thweek4.global.security.util.SecurityUtil; +import com.example.umc10thweek4.global.security.entity.AuthMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -20,10 +21,11 @@ public class ReviewController { @PostMapping("/v1/stores/{storeId}/reviews") public ResponseEntity> createReview( + @AuthenticationPrincipal AuthMember authMember, @PathVariable Long storeId, @RequestBody @Valid ReviewReqDTO.Create request) { - Long currentMemberId = SecurityUtil.getCurrentMemberId(); + Long currentMemberId = authMember.getMember().getId(); Long userMissionId = 1L; // 임시 값 @@ -32,6 +34,19 @@ public ResponseEntity> createReview( return ApiResponse.onSuccessResponse(ReviewSuccessCode.CREATE_SUCCESS, response); } + @GetMapping("/v1/users/me/reviews") + public ResponseEntity>> getMyReviews( + @AuthenticationPrincipal AuthMember authMember, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) ReviewReqDTO.SortType sort) { // cursor = "reviewId:createdAt" 형태 + + Long currentMemberId = authMember.getMember().getId(); + + return ApiResponse.onSuccessResponse(ReviewSuccessCode.LIST_SUCCESS, + reviewService.getMyReviews(currentMemberId, pageSize, cursor, sort)); + } + @GetMapping("/v1/users/{userId}/reviews") public ResponseEntity>> getMyReviews( @PathVariable Long userId, diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/apiPayload/handler/GeneralExceptionAdvice.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/apiPayload/handler/GeneralExceptionAdvice.java index 12bde6a9..3e7a42c4 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -5,6 +5,7 @@ import com.example.umc10thweek4.global.apiPayload.code.GeneralErrorCode; import com.example.umc10thweek4.global.apiPayload.exception.ProjectException; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -24,6 +25,15 @@ public ResponseEntity> handleMemberException( return ApiResponse.onFailureResponse(errorCode, null); } + // 로그인 인증 실패 예외 처리 + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException( + AuthenticationException e + ) { + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + return ApiResponse.onFailureResponse(code, null); + } + // 그 외의 정의되지 않은 모든 예외 처리 @ExceptionHandler(Exception.class) public ResponseEntity> handleException( diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/config/SecurityConfig.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/config/SecurityConfig.java index dc83eca9..ecc70a22 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/config/SecurityConfig.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/config/SecurityConfig.java @@ -3,15 +3,22 @@ import com.example.umc10thweek4.global.security.provider.CustomAuthenticationProvider; import com.example.umc10thweek4.global.security.handler.CustomAccessDeniedHandler; import com.example.umc10thweek4.global.security.handler.CustomAuthenticationEntryPoint; -import com.example.umc10thweek4.global.security.handler.CustomAuthenticationFailureHandler; -import com.example.umc10thweek4.global.security.handler.CustomAuthenticationSuccessHandler; +import com.example.umc10thweek4.global.security.handler.OAuthSuccessHandler; +import com.example.umc10thweek4.global.security.filter.JwtAuthFilter; +import com.example.umc10thweek4.global.security.service.CustomOAuthService; +import com.example.umc10thweek4.global.security.service.CustomUserDetailsService; +import com.example.umc10thweek4.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @EnableWebSecurity @Configuration @@ -21,8 +28,14 @@ public class SecurityConfig { private final CustomAuthenticationProvider customAuthenticationProvider; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; - private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; - private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler; + private final CustomOAuthService customOAuthService; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } private final String[] publicUris = { // Swagger 허용 @@ -32,7 +45,9 @@ public class SecurityConfig { // 인증 없이 접근 가능한 API "/auth/signup", - "/auth/login" + "/auth/login", + "/oauth2/authorization/**", + "/oauth/callback/**" }; @Bean @@ -40,26 +55,36 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .authenticationProvider(customAuthenticationProvider) .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) .authorizeHttpRequests(requests -> requests .requestMatchers(publicUris).permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .loginProcessingUrl("/auth/login") - .usernameParameter("email") - .passwordParameter("password") - .successHandler(customAuthenticationSuccessHandler) - .failureHandler(customAuthenticationFailureHandler) - .permitAll() - ) - .logout(logout -> logout - .logoutUrl("/auth/logout") + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .oauth2Login(oauth -> oauth + .redirectionEndpoint(redirection -> redirection + .baseUri("/oauth/callback/*") + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + .successHandler(oAuthSuccessHandler) ) .exceptionHandling(exception -> exception .authenticationEntryPoint(customAuthenticationEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) - ); + ) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager(customAuthenticationProvider); + } } diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/controller/AuthController.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/controller/AuthController.java index dd41957d..ddb14c17 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/controller/AuthController.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/controller/AuthController.java @@ -5,6 +5,10 @@ import com.example.umc10thweek4.domain.member.exception.code.MemberSuccessCode; import com.example.umc10thweek4.domain.member.service.MemberService; import com.example.umc10thweek4.global.apiPayload.ApiResponse; +import com.example.umc10thweek4.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc10thweek4.global.security.dto.AuthReqDTO; +import com.example.umc10thweek4.global.security.dto.AuthResDTO; +import com.example.umc10thweek4.global.security.service.AuthService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -19,9 +23,15 @@ public class AuthController { private final MemberService memberService; + private final AuthService authService; @PostMapping("/signup") public ResponseEntity> signUp(@RequestBody @Valid MemberReqDTO.SignUp request) { return ApiResponse.onSuccessResponse(MemberSuccessCode.SIGN_UP_SUCCESS, memberService.signUp(request)); } + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody @Valid AuthReqDTO.Login request) { + return ApiResponse.onSuccessResponse(GeneralSuccessCode.OK, authService.login(request)); + } } diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthReqDTO.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthReqDTO.java new file mode 100644 index 00000000..51eafba1 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthReqDTO.java @@ -0,0 +1,16 @@ +package com.example.umc10thweek4.global.security.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class AuthReqDTO { + + public record Login( + @NotBlank(message = "이메일을 입력해주세요") + @Email(message = "올바른 이메일 형식이 아닙니다") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요") + String password + ) {} +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthResDTO.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthResDTO.java new file mode 100644 index 00000000..d1916dc3 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/AuthResDTO.java @@ -0,0 +1,13 @@ +package com.example.umc10thweek4.global.security.dto; + +public class AuthResDTO { + + public record Login( + String tokenType, + String accessToken + ) { + public static Login of(String accessToken) { + return new Login("Bearer", accessToken); + } + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/KakaoDTO.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..95da345c --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/KakaoDTO.java @@ -0,0 +1,39 @@ +package com.example.umc10thweek4.global.security.dto; + +import com.example.umc10thweek4.domain.member.enums.SocialType; +import com.example.umc10thweek4.domain.member.exception.MemberException; +import com.example.umc10thweek4.domain.member.exception.code.MemberErrorCode; + +import java.util.Map; + +public record KakaoDTO( + String socialUid, + String email, + String nickname +) implements OAuthDTO { + + @Override + public SocialType socialType() { + return SocialType.KAKAO; + } + + @SuppressWarnings("unchecked") + public static KakaoDTO from(String socialUid, Map attributes) { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = kakaoAccount == null + ? null + : (Map) kakaoAccount.get("profile"); + + String email = getRequiredString(kakaoAccount, "email"); + String nickname = getRequiredString(profile, "nickname"); + + return new KakaoDTO(socialUid, email, nickname); + } + + private static String getRequiredString(Map attributes, String key) { + if (attributes == null || attributes.get(key) == null) { + throw new MemberException(MemberErrorCode.OAUTH_REQUIRED_ATTRIBUTE_NOT_FOUND); + } + return attributes.get(key).toString(); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/OAuthDTO.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..5c4d2227 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/dto/OAuthDTO.java @@ -0,0 +1,25 @@ +package com.example.umc10thweek4.global.security.dto; + +import com.example.umc10thweek4.domain.member.enums.SocialType; +import com.example.umc10thweek4.domain.member.exception.MemberException; +import com.example.umc10thweek4.domain.member.exception.code.MemberErrorCode; + +import java.util.Map; + +public interface OAuthDTO { + + SocialType socialType(); + + String socialUid(); + + String email(); + + String nickname(); + + static OAuthDTO from(SocialType socialType, String socialUid, Map attributes) { + return switch (socialType) { + case KAKAO -> KakaoDTO.from(socialUid, attributes); + default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + }; + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/entity/OAuthMember.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..884afd1c --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/entity/OAuthMember.java @@ -0,0 +1,34 @@ +package com.example.umc10thweek4.global.security.entity; + +import com.example.umc10thweek4.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + private final Member member; + private final Map attributes; + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getName() { + return member.getEmail(); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/filter/JwtAuthFilter.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..8d541bb2 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,89 @@ +package com.example.umc10thweek4.global.security.filter; + +import com.example.umc10thweek4.global.apiPayload.ApiResponse; +import com.example.umc10thweek4.global.apiPayload.code.BaseErrorCode; +import com.example.umc10thweek4.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10thweek4.global.security.service.CustomUserDetailsService; +import com.example.umc10thweek4.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + String token = resolveToken(request); + + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + if (jwtUtil.isValid(token) + && SecurityContextHolder.getContext().getAuthentication() == null) { + + String email = jwtUtil.getEmail(token); + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + + ((UsernamePasswordAuthenticationToken) auth) + .setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } catch (Exception e) { + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setStatus(code.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + objectMapper.writeValue(response.getWriter(), errorResponse); + } + } + + private String resolveToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) { + return null; + } + + return authorizationHeader.substring(BEARER_PREFIX.length()); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAccessDeniedHandler.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAccessDeniedHandler.java index 186ad585..a73e4fda 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAccessDeniedHandler.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAccessDeniedHandler.java @@ -1,20 +1,22 @@ package com.example.umc10thweek4.global.security.handler; +import com.example.umc10thweek4.global.apiPayload.ApiResponse; import com.example.umc10thweek4.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import java.io.IOException; +import java.nio.charset.StandardCharsets; @Component -@RequiredArgsConstructor public class CustomAccessDeniedHandler implements AccessDeniedHandler { - private final SecurityResponseWriter securityResponseWriter; + private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void handle( @@ -22,6 +24,9 @@ public void handle( HttpServletResponse response, AccessDeniedException accessDeniedException ) throws IOException { - securityResponseWriter.writeFailure(response, GeneralErrorCode.FORBIDDEN); + response.setStatus(GeneralErrorCode.FORBIDDEN.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(GeneralErrorCode.FORBIDDEN, null)); } } diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationEntryPoint.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationEntryPoint.java index 0a9c89e4..bd7fdaa5 100644 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationEntryPoint.java +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationEntryPoint.java @@ -1,20 +1,22 @@ package com.example.umc10thweek4.global.security.handler; +import com.example.umc10thweek4.global.apiPayload.ApiResponse; import com.example.umc10thweek4.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import java.io.IOException; +import java.nio.charset.StandardCharsets; @Component -@RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { - private final SecurityResponseWriter securityResponseWriter; + private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void commence( @@ -22,6 +24,9 @@ public void commence( HttpServletResponse response, AuthenticationException authException ) throws IOException { - securityResponseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED); + response.setStatus(GeneralErrorCode.UNAUTHORIZED.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null)); } } diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationFailureHandler.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationFailureHandler.java deleted file mode 100644 index 6fa9b881..00000000 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationFailureHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.umc10thweek4.global.security.handler; - -import com.example.umc10thweek4.global.apiPayload.code.GeneralErrorCode; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { - - private final SecurityResponseWriter securityResponseWriter; - - @Override - public void onAuthenticationFailure( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception - ) throws IOException { - securityResponseWriter.writeFailure(response, GeneralErrorCode.UNAUTHORIZED); - } -} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationSuccessHandler.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationSuccessHandler.java deleted file mode 100644 index c96a9e1d..00000000 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/CustomAuthenticationSuccessHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.umc10thweek4.global.security.handler; - -import com.example.umc10thweek4.global.apiPayload.code.GeneralSuccessCode; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { - - private final SecurityResponseWriter securityResponseWriter; - - @Override - public void onAuthenticationSuccess( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) throws IOException { - securityResponseWriter.writeSuccess(response, GeneralSuccessCode.OK); - } -} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/OAuthSuccessHandler.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..1e6367c6 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,59 @@ +package com.example.umc10thweek4.global.security.handler; + +import com.example.umc10thweek4.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10thweek4.global.apiPayload.ApiResponse; +import com.example.umc10thweek4.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10thweek4.global.security.dto.AuthResDTO; +import com.example.umc10thweek4.global.security.entity.AuthMember; +import com.example.umc10thweek4.global.security.entity.OAuthMember; +import com.example.umc10thweek4.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + // 사전 작업: Response 매핑할 ObjectMapper 선언 + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = MemberSuccessCode.OK; + + // Content-Type, Status 설정 + response.setStatus(code.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) authentication.getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + AuthResDTO.Login.of(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getWriter(), responseBody); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/SecurityResponseWriter.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/SecurityResponseWriter.java deleted file mode 100644 index 6b028c7e..00000000 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/handler/SecurityResponseWriter.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.umc10thweek4.global.security.handler; - -import com.example.umc10thweek4.global.apiPayload.ApiResponse; -import com.example.umc10thweek4.global.apiPayload.code.BaseErrorCode; -import com.example.umc10thweek4.global.apiPayload.code.BaseSuccessCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -@Component -public class SecurityResponseWriter { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - public void writeFailure(HttpServletResponse response, BaseErrorCode errorCode) throws IOException { - response.setStatus(errorCode.getStatus().value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(errorCode, null)); - } - - public void writeSuccess(HttpServletResponse response, BaseSuccessCode successCode) throws IOException { - response.setStatus(successCode.getStatus().value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - objectMapper.writeValue(response.getWriter(), ApiResponse.onSuccess(successCode, null)); - } -} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/AuthService.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/AuthService.java new file mode 100644 index 00000000..0b7baf3a --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/AuthService.java @@ -0,0 +1,30 @@ +package com.example.umc10thweek4.global.security.service; + +import com.example.umc10thweek4.global.security.dto.AuthReqDTO; +import com.example.umc10thweek4.global.security.dto.AuthResDTO; +import com.example.umc10thweek4.global.security.entity.AuthMember; +import com.example.umc10thweek4.global.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + public AuthResDTO.Login login(AuthReqDTO.Login request) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); + + AuthMember authMember = (AuthMember) authentication.getPrincipal(); + String accessToken = jwtUtil.createAccessToken(authMember); + + return AuthResDTO.Login.of(accessToken); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/CustomOAuthService.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..8a4fefc2 --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/service/CustomOAuthService.java @@ -0,0 +1,63 @@ +package com.example.umc10thweek4.global.security.service; + +import com.example.umc10thweek4.domain.member.converter.MemberConverter; +import com.example.umc10thweek4.domain.member.entity.Member; +import com.example.umc10thweek4.domain.member.entity.mapping.MemberNoticeSetting; +import com.example.umc10thweek4.domain.member.enums.SocialType; +import com.example.umc10thweek4.domain.member.exception.MemberException; +import com.example.umc10thweek4.domain.member.exception.code.MemberErrorCode; +import com.example.umc10thweek4.domain.member.repository.MemberNoticeSettingRepository; +import com.example.umc10thweek4.domain.member.repository.MemberRepository; +import com.example.umc10thweek4.global.security.dto.OAuthDTO; +import com.example.umc10thweek4.global.security.entity.OAuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + private final MemberNoticeSettingRepository noticeSettingRepository; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + // (필수) 인증 서버의 일회성 토큰을 이용해 정보 조회 & 유저 객체 생성 + OAuth2User oAuthMember = super.loadUser(userRequest); + + // 유저 객체에서 정보 추출 + SocialType providerId; + String socialUid; + try { + providerId = SocialType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialUid = String.valueOf(oAuthMember.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + OAuthDTO dto = OAuthDTO.from(providerId, socialUid, oAuthMember.getAttributes()); + + // DB 저장: 있다면 그 데이터 가져오고 없으면 새로 저장 + Member member = memberRepository.findBySocialTypeAndSocialUidAndDeletedAtIsNull(providerId, socialUid) + .or(() -> memberRepository.findActiveByEmail(dto.email())) + .orElseGet(() -> { + Member newMember = memberRepository.save(MemberConverter.toMember(dto)); + noticeSettingRepository.save( + MemberNoticeSetting.builder() + .member(newMember) + .getNewEvent(true) + .getReviewComment(true) + .getAskComment(true) + .build() + ); + return newMember; + }); + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/JwtUtil.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/JwtUtil.java new file mode 100644 index 00000000..89fb080d --- /dev/null +++ b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/JwtUtil.java @@ -0,0 +1,93 @@ +package com.example.umc10thweek4.global.security.util; + +import com.example.umc10thweek4.global.security.entity.AuthMember; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", member.getUsername()) + .issuedAt(Date.from(now)) // 언제 발급한지 + .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 + .signWith(secretKey) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} diff --git a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/SecurityUtil.java b/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/SecurityUtil.java deleted file mode 100644 index 0b533e24..00000000 --- a/Chanyeol/src/main/java/com/example/umc10thweek4/global/security/util/SecurityUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.umc10thweek4.global.security.util; - -import com.example.umc10thweek4.global.security.entity.AuthMember; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; - -public class SecurityUtil { - - public static AuthMember getCurrentAuthMember() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null || !(authentication.getPrincipal() instanceof AuthMember - authMember)) { - throw new RuntimeException("로그인 정보가 없습니다."); - } - - return authMember; - } - - public static Long getCurrentMemberId() { - return getCurrentAuthMember().getMember().getId(); - } -}