diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..9f11b755 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 8713e6d2..00000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - 1775064304989 - - - - - - \ No newline at end of file diff --git a/Gibeom/umc10th/.env b/Gibeom/umc10th/.env deleted file mode 100644 index 43b455e5..00000000 --- a/Gibeom/umc10th/.env +++ /dev/null @@ -1,3 +0,0 @@ -DB_USER=USER -DB_PW=MY_PASSWORD_HERE -DB_URL=MY_URL \ No newline at end of file diff --git a/Gibeom/umc10th/build.gradle b/Gibeom/umc10th/build.gradle index 2314bead..b3624447 100644 --- a/Gibeom/umc10th/build.gradle +++ b/Gibeom/umc10th/build.gradle @@ -22,6 +22,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-security' + + // 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' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' @@ -31,9 +38,16 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/Gibeom/umc10th/feedback_summary/FeedBack_Study b/Gibeom/umc10th/feedback_summary/FeedBack_Study deleted file mode 100644 index 04d93ed2..00000000 --- a/Gibeom/umc10th/feedback_summary/FeedBack_Study +++ /dev/null @@ -1,26 +0,0 @@ -ResponseEntity -- 정의 : 스프링 프레임워크에서 제공하는 클래스. -- Http요청 또는 응답에 해당하는 HttpStatus, HttpHeader와 HttpBody를 포함하는 클래스 -- Http 응답의 주권을 가짐. 커스텀 가능 -- 상속 구현 클래스 (RequestBody, ResponseEntity) - -기존 상태 -- 클라이언트 측에서는 ApiResponse로 응답시 정해진 응답 (예 : 200이면 200, 500이면 500) -- 현재 ApiResponse 클래스는 정적 팩토리 메서드 형식. -- ApiResponse.java - ``` - //성공한 경우 - public static ApiResponse onSuccess(BaseSuccessCode code, T result){ - return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); - } - ``` -기존 방식의 한계 -- 클라이언트 측은 먼저 http status를 확인하고 바디를 확인함 -- 네트워크 불일치 : ApiResponse만 반환하면 내부 로직이 실패하더라도 Http 상태코드는 기본(200)으로 나가는 경우가 많았음 -- 상세한 정보가 Json바디 안에 숨겨져 있어 클라이언트가 Http 표준방식으로 1차 판단을 내리기 어려웠음 -- 단순한 소통 : 200, 500 위주의 단순한 소통만 가능했고, 201, 204 등 을 활용 못했음 - -Why? -- API 사용자(프론트엔드 등등)는 응답바디 안의 Json뿐 아닌 브라우저나 앱이 수신하는 Status Code를 보고 1차 판단을 내림. -- 유연성 : ResponseEntity를 래핑해 감싼 응답을 보낸다면 커스텀 응답, 쿠키, 헤더 등 표준화된 응답을 유연하게 보낼 수 있음. -- ApiResponse의 데이터 규격을 유지하고 ResponseEntity로 감싸 네트워크 수준 상태코드로 명시적 관리 \ No newline at end of file diff --git a/Gibeom/umc10th/keyword_summary/ch07.md b/Gibeom/umc10th/keyword_summary/ch07.md deleted file mode 100644 index 701095b1..00000000 --- a/Gibeom/umc10th/keyword_summary/ch07.md +++ /dev/null @@ -1,90 +0,0 @@ -- Page와 Slice - - Spring Data JPA는 페이징을 위해 두가지 객체를 제공한다. Slice & Page - - DB에 저장된 데이터들을 페이지에 맞춰 몇개씩 뿌릴 건지 알려주는 것 - - - Page - - pageable 객체를 사용해 오프셋 기반 페이지네이션을 구현할 수 있는 객체. - - Slice를 상속함 - - Slice - - Page 보다 좀 더 추상적. - - 커서 기반 페이지네이션을 구현할 때 사용할 수 있는 객체 - - Response에 총 데이터 갯수는 보내지 않음 - - Pageable 객체 - - JPA가 제공하는 페이지네이션을 위한 객체 - - 페이징 정보(페이지 번호, 페이지 크기, 정렬방식 등..)를 담고 있는 인터페이스 - - 스프링 Data JPA에서 제공하는 `PageRequest` 클래스를 통해 인스턴스화 가능 - - 사용 이유 - - 모든 데이터를 한번에 뿌리게 되면 성능 저하 등 문제가 발생 - - 페이지네이션을 사용해 적절한 수의 정보만 올려주면 프론트에서 적절한 처리가능. -- Java stream API - - 자바가 지원하는 함수형 프로그래밍 방식. - - 함수형 프로그래밍이란? - - - 코드가 “어떻게” 에서 “무엇을”로 포커싱이 옮겨간 프로그래밍 방식 - - 함수형 프로그래밍에서 함수는 `Int` ,`String` 같은 1급 객체로 취급. - - 이 특성을 활용하는 고차함수 : map, filter, reduce 등… - - (기존 객체의 상태를 변경하지 않고, 새로운 결과를 반환) - - 사용 이유 : 가독성, 불변성, 유지보수성, 병렬 처리성 (쓰레드 풀) - - 단점 - - - for문 같은 단순 반복문에 익숙한 개발자들에게는 람다식과 스트림이 이해가 어려울 수 있음 - - 중단점을 걸어서 디버깅하기 어려움. - - for문 보다 오버헤드가 조금 더 큼. - - 병렬 처리 - - - 작업을 여러 스레드에게 분할해 병렬적으로 처리하는 방법 - - `parallelStram()` 키워드와 `ForkJoinPool()` 을 사용해 스레드 지정 가능 - - CommonPool : 미리 생성해 놓은 스레드를 모든 애플리케이션이 이용하는 방식의 풀 - - 장점 : 정해진 수의 스레드를 미리 생성해 놓고 사용하기 때문에 스레드 생성/삭제 오버헤드 적음. 적은 양의 처리에 유리 - - 단점 : 각각의 애플리케이션 마다 알맞은 처리를 제공하기 힘들 수 있음 - - (군대 배식 느낌) - - ThreadPool : 각각의 컴포넌트마다 설정할 수 있는 풀 - - 장점 : 성능 튜닝 가능 - - 메모리 관리 등 신경써야 할 것들이 있음. - - (오마카세 느낌) -- 객체 그래프 탐색 - - 객체 그래프 탐색 (Object Graph Navigation) - - - 객체지향언어에서 참조를 사용해 연관된 객체를 타고 들어가 데이터를 조회하는 방식 - - ex) 멤버 객체에서 member.getTeam()처럼 연관관계를 통해 팀 정보를 가져오는 방식 - - 특징 : 객체 간 연관관계를 통해 자유롭게 메모리상의 객체를 이동하며 탐색 - - 장점 : SQL 직접 작성 시 필요한 조인 제약에서 벗어나 논리적인 도메인 모델 구조에 따라 데이터 조회 가능 - - JPA에서의 활용 : JPA는 연관된 객체를 처음부터 로딩하지 않고 실제 사용 시점에 조회하는 지연로딩을 사용해 객체그래프 탐색을 지원 - - 주의점 : 객체 그래프를 무분별 탐색하는 경우, 하이버네이트 등에서 N+1 문제가 발생할 수 있음. - - **내 요약** - - - Store store = member.getReviewList().get(0).getStore()와 같은 코드가 있다고 가정. - 1. 멤버의 리뷰리스트를 가져오고 - 2. 0번 객체를 가져오고 - 3. 그 리뷰의 가게를 가져오는 흐름을 표현 가능. - - 여기서 문제 : 어디까지 조회가 가능한가? - - 실행시 모든 데이터를 올려둘 수는 없음. 그렇다면 사용할 때만 필요한 정보를 가져오는 방식 Lazy-Loading(지연로딩)을 사용해 `.` 을 사용해 다음 객체를 불러올때마다 데이터를 가져옴. - - 만약 객체간 연결이 없거나, 다음 데이터를 조회할 수 없다면 null이 조회되거나 오류가 날 것. -- @Valid vs @Validated - - @Valid - - - 컨트롤러 단에서 객체의 유효성을 검사할 때 사용 - - 계층 구조 검증이 가능 (User 안에 있는 Address 객체도 검사하고 싶으면 Address 필드 위에 @Valid 를 붙여야 함) - - 한계 : 그룹화 검증이 불가능 - - @Validated - - - 스프링에서 자바표준기능을 확장해 만든 어노테이션 - - 용도 : Service 나 Repository 등 Bean 계층에서 검증이 필요할 때, 혹은 그룹검증이 필요할 때 사용. - - 특징 - - 제약조건 그룹화 : “회원가입때는 이 필드 검사하고, 정보수정때는 하지마” 같은 상황을 `groups` 속성으로 지정가능 - - 클래스 레벨 선언 : 클래스 상단에 `@Validated` 어노테이션을 붙여야 해당 클래스 내부의 메서드 파라미터에 대한 검증이 작동 - - 한계 : 계층구조 검증은 직접적으로 지원하지 않아, 내부 객체에는 여전히 @Valid를 써야함. \ No newline at end of file diff --git a/Gibeom/umc10th/keyword_summary/ch08.md b/Gibeom/umc10th/keyword_summary/ch08.md new file mode 100644 index 00000000..737accb1 --- /dev/null +++ b/Gibeom/umc10th/keyword_summary/ch08.md @@ -0,0 +1,42 @@ +- Spring Security가 무엇인가? + + 스프링 기반 애플리케이션의 보안을 담당하는 강력하고 포괄적인 하위 프레임워크 + + 복잡한 보안 로직을 직접 구현할 필요 없이 표준화된 필터 기반의 설정을 통해 시스템을 안전하게 보호한다. + +- 인증(Authentication)vs 인가(Authorization) + + 비슷해보이지만 서로 다른 개념이다. + + 인증 (Authentication) + + - 본인확인 절차 + - 사용자가 자신이 주장하는 사람이 맞는지 확인하는 과정 + + 인가 (Authorization) + + - 권한확인 절차 + - 인증된 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정 + +- Stateful vs Stateless + + 논점 : 서버가 클라이언트의 세션 정보를 기억하는가? + + Stateful(상태유지) : 세션 정보를 기억함 + + Stateless(토큰 기반) : 서버가 상태를 유지하지 않으므로 요청에 포함된 토큰(JWT)로 검증 + + | 구분 | Stateful | Stateless | + | --- | --- | --- | + | 특징 | 서버가 세션 저장소에 로그인 상태 유지 | 서버가 상태를 유지하지 않음, 요청에 포함된 토큰으로 검증 | + | 인증방식 | JSESSION쿠키를 통해 서버 메모리/DB의 세션 조회 | 매 요청시 HTTP헤더에 토큰을 담아서 전송 (Authorization:Bearer) | + | 서버 확장 | 세션 불일치 문제 발생 가능 | 각 요청이 독립적이므로 서버 증설에 유리 | + | 메모리 및 비용 | 동시접속자가 많을수록 서버 세션 메모리 소비 증가 | 토큰 검증 연산이 필요하며, 서버 메모리 사용량은 적음 | + | 주요 활용처 | 전톤적인 웹 애플리케이션 | REST API, 모바일 앱, MSA | + + 서버 확장 방법 + + - Scale-up : 단일 서버 성능 향상 + - Scale-out : 서버의 개수를 늘리기 + - 로드밸런서 : 서버 부하를 분산시키는 H/W, S/W + - 클라이언트와 서버Pool 사이에 위치해 서버의 부하를 분산시키는 하드웨어나 S/W \ No newline at end of file diff --git a/Gibeom/umc10th/keyword_summary/ch09.md b/Gibeom/umc10th/keyword_summary/ch09.md new file mode 100644 index 00000000..df76c882 --- /dev/null +++ b/Gibeom/umc10th/keyword_summary/ch09.md @@ -0,0 +1,53 @@ +- 세션과 토큰의 차이는? + + 세션 방식은 사용자의 정보를 서버가 저장함 + + - 이용자 수가 많으면 서버 부하가 커짐(모든 사용자 정보를 테이블로 보유하고 있음) + + 토큰 방식은 사용자가 내 정보가 포함된 토큰을 내밀기만 하면 됨 + + - 서버의 부담이 적음. + - 다만 사용자의 정보를 저장하지 않기 때문에 할 수 없는 기능도 있음. + + 세션 : 입장권인데 적혀있는 게 거의 없음. 사이트가 입장권 검사하고 저장해놓음. + + 토큰 : 뭐가 많이 적혀있는 입장권. 사이트는 입장권 들고있으면 입장 허용해줌. + + (참고자료) https://youtu.be/XXseiON9CV0?si=Am8JlSoLV0SiZX0W + +- 엑세스 토큰과 리프레시 토큰이란? + + 액세스 토큰 : 접근하기 위해 필요한 기본적인 토큰, 해킹 공격 방지를 위해 유효기간이 짧다. + + 리프레쉬 토큰 : 만료된 액세스 토큰을 재생성해주는 토큰. + +- OAuth 1.0과 OAuth 2.0의 차이는? + + OAuth 2.0 은 1.0과 달리 복잡한 암호화 서명 과정을 간소화하고, 웹/모바일 등 다양한 환경에 맞춘 인증 방식을 도입해 속도와 확장성을 대폭 개선한 버전 + + 핵심 차이점 + + | 구분 | OAuth 1.0 | OAuth 2.0 | + | --- | --- | --- | + | 핵심 매커니즘 | 모든 요청에 디지털 서명 필요 | 발급받은 액세스 토큰만으로 인증 | + | 암호화 요구사항 | HTTPS가 필수가 아님 | HTTPS(TLS) 필수 | + | 역할의 분리 | 단순함(Client, Server, User) | 인증서버와 리소스 서버의 분리 | + | 토큰 만료 | 토큰 만료 개념이 없거나 복잡 | 만료기간이 존재하며, 리프레시 토큰으로 갱신가능 | + | 지원 환경 | 웹 브라우저 기반 애플리케이션 중심 | 모바일 앱, Iot, 데스크톱 등 다양한 환경 지원 | + + + 왜 2.0으로 바뀌었을까? + + 1. 개발의 복잡성 해결(디지털 서명의 폐지) + + 1.0은 API를 호출할 때마다 복잡한 암호화 알고리즘으로 디지털 서명을 생성하고 이를 헤더에 담아 보내야 했음. 이 서명을 만드는 과정이 조금만 틀려도 인증이 실패했기 때문에 매우 불편했음. + + 2.0은 이 서명 과정을 과감히 버렸음. 대신 HTTPS통신을 필수로 규정해 연결 자체를 암호화하고, 발급받은 문자열 토큰만 헤더에 얹어서 보내면 되도록 단순화 했음 + + 2. 모바일 및 다양한 디바이스 지원 + + OAuth 1.0이 나올 당시에는 웹브라우저 중심의 환경이었음. 하지만 모바일 앱, IoT 등의 기기들이 생기면서 브라우저가 없거나 화면이 없는 환경에서도 인증을 처리해야 할 필요가 생김. OAuth 2.0은 시나리오별 인증방식을 여러개 제공해 맞춤형 인증을 제공함 + + 3. 대규모 서비스 분할(확장성) + + OAuth 2.0은 인증을 담당하는 서버와 실제 데이터를 가지고 있는 서버의 역할을 명확히 나눴음. 덕분에 대기업이나 대규모 서비스에서는 인증서버만 따로 구축해 트래픽을 분산할 수 있음. \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index c61e09c2..7ce6a23e 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -1,5 +1,6 @@ package com.example.umc10th.domain.member.controller; +import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.enums.MissionStatus; import com.example.umc10th.domain.member.service.MemberService; @@ -7,8 +8,9 @@ import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -17,37 +19,35 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/members") +@RequestMapping("/api") public class MemberController { private final MemberService memberService; //마이페이지 - @GetMapping("/me") + @GetMapping("/v1/members/me") public ResponseEntity> getInfo( - @AuthenticationPrincipal Long memberId - ){ + @AuthenticationPrincipal AuthMember member + ){ BaseSuccessCode code = MemberSuccessCode.OK; - MemberResDTO.GetInfo result = memberService.getInfo(memberId); return ResponseEntity .status(code.getStatus()) - .body(ApiResponse.onSuccess(code, result)); + .body(ApiResponse.onSuccess(code, memberService.getInfo(member))); } // 홈화면 - @GetMapping("/home") + @GetMapping("/v1/members/home") public ResponseEntity> getHome( - @AuthenticationPrincipal Long memberId, + @AuthenticationPrincipal AuthMember authMember, @RequestParam(defaultValue = "0") int page ){ - BaseSuccessCode code = MemberSuccessCode.OK; - MemberResDTO.HomeResultDto result = memberService.getHome(memberId, page); + MemberResDTO.HomeResultDto result = memberService.getHome(authMember.getMember().getId(), page); return ResponseEntity - .status(code.getStatus()) - .body(ApiResponse.onSuccess(code, result)); + .status(MemberSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.OK, result)); } // 진행중/완료 미션 목록 조회 - @GetMapping("/missions") + @GetMapping("/v1/members/missions") public ResponseEntity>> getMissionsByStatus( @AuthenticationPrincipal Long memberId, @RequestParam MissionStatus status, @@ -62,4 +62,28 @@ public ResponseEntity>> getMissionsBy .body(ApiResponse.onSuccess(code, result)); } + + //회원가입 + @PostMapping("/auth/sign-up") + public ResponseEntity> signUp( + @RequestBody @Valid MemberReqDTO.SignUp req + ){ + memberService.signUp(req); + return ResponseEntity + .status(MemberSuccessCode.CREATED.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.CREATED, null)); + } + + //로그인 + @PostMapping("/auth/login") + public ResponseEntity> login( + @RequestBody @Valid MemberReqDTO.Login req + ){ + MemberResDTO.LoginResult result = memberService.login(req); + return ResponseEntity + .status(MemberSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.OK, result)); + } + + } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index a7412b8f..baf0a23e 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -4,9 +4,14 @@ import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; import com.example.umc10th.domain.member.entity.mapping.MemberMission; +import com.example.umc10th.global.security.dto.OAuthDTO; import org.springframework.data.domain.Page; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.Collection; import java.util.List; import java.util.stream.Collectors; @@ -44,4 +49,39 @@ public static MemberResDTO.HomeMissionDto toHomeMissionDto(MemberMission memberM .status(memberMission.getStatus().name()) .build(); } + + public static MemberResDTO.LoginResult toLoginResult(String accessToken) { + return MemberResDTO.LoginResult.builder() + .accessToken(accessToken) + .build(); + } + + public static Member toMember(MemberReqDTO.SignUp req, String encodedPasssword){ + return Member.builder() + .name(req.name()) + .password(encodedPasssword) + .phoneNumber(req.phoneNumber()) + .email(req.email()) + .gender(req.gender()) + .userPoint(0) + .nickname(req.nickname()) + .build(); + } + + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.getName()) + .nickname(dto.getName()) + .email(dto.getSocialEmail()) + .socialType(dto.getSocialType()) + .socialUid(dto.getSocialUid()) + .userPoint(0) + .build(); + } + + public static MemberResDTO.Login toLogin(String accessToken) { + return MemberResDTO.Login.builder() + .accessToken(accessToken) + .build(); + } } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index 943a141f..b16230df 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -1,5 +1,31 @@ package com.example.umc10th.domain.member.dto; +import com.example.umc10th.domain.member.enums.Gender; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + public class MemberReqDTO { + public record SignUp ( + @NotBlank + String name, + @NotBlank + String nickname, + @Email @NotBlank + String email, + @NotBlank + String password, + @NotBlank + String phoneNumber, + Gender gender + ){} + + public record Login( + @Email @NotBlank + String email, + @NotBlank + String password + ){} + } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index 9da8ee56..4b672d47 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -15,6 +15,16 @@ public record GetInfo( Integer userPoint ){} + @Builder + public record LoginResult( + String accessToken + ){} + + @Builder + public record Login( + String accessToken + ){} + //홈 화면 @Builder public record HomeResultDto( diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 1f7fca6b..fb197716 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -4,6 +4,7 @@ import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.store.entity.Region; import com.example.umc10th.global.entity.BaseEntity; +import com.example.umc10th.global.security.dto.SocialType; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -37,7 +38,7 @@ public class Member extends BaseEntity{ @Column(name = "nickname", nullable = false) private String nickname; - @Column(name = "phoneNumber", nullable = false) + @Column(name = "phoneNumber") private String phoneNumber; @Column(name = "user_point") @@ -46,6 +47,16 @@ public class Member extends BaseEntity{ @Column(name = "email") private String email; + @Column(name = "password") + private String password; + + @Column(name = "social_type") + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Column(name = "social_uid") + private String socialUid; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "region_id") private Region region; diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index 98c24940..844b6740 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -12,7 +12,23 @@ public enum MemberErrorCode implements BaseErrorCode { "COMMON404_1", "해당 사용자를 찾을 수 없습니다." ), + EMAIL_DUPLICATED(HttpStatus.CONFLICT, + "COMMON404_2", + "해당 사용자를 찾을 수 없습니다." + ), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, + "COMMON409_2", + "이미 존재하는 닉네임입니다." + ), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, + "MEMBER401_1", + "비밀번호가 올바르지 않습니다." + ), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, + "404_3", + "제공하지 않는 인증자입니다." + ), ; private final HttpStatus status; private final String code; diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index 5a5c3362..2d76db0b 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -11,7 +11,7 @@ public enum MemberSuccessCode implements BaseSuccessCode { "MEMBER200_1", "성공적으로 유저를 조회했습니다."), - SIGNUP_SUCCESS(HttpStatus.CREATED, + CREATED(HttpStatus.CREATED, "MEMBER_201_1", "회원가입이 성공적으로 완료되었습니다.") ; diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index 054ad34f..70a4b847 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,7 +1,14 @@ package com.example.umc10th.domain.member.repository; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.global.security.dto.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MemberRepository extends JpaRepository { + Boolean existsByEmail(String email); + Optional findByEmail(String email); + Boolean existsByNickname(String nickname); + Optional findBySocialTypeAndSocialUid(SocialType socialType, String socialUid); } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 70b5aae5..bfb68f16 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -13,10 +13,13 @@ import com.example.umc10th.domain.mission.converter.MissionConverter; import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.mission.entity.Mission; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; @@ -27,11 +30,16 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberMissionRepository memberMissionRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; - public MemberResDTO.GetInfo getInfo(Long memberId) { - Member member = memberRepository.findById(memberId) + public MemberResDTO.GetInfo getInfo( + AuthMember member + ) { + Member User = member.getMember(); + memberRepository.findByEmail(User.getEmail()) .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); - return MemberConverter.toGetInfo(member); + return MemberConverter.toGetInfo(User); } //홈화면 (진행중인 미션 10개씩 페이징) @@ -65,4 +73,27 @@ public List getMissionsByStatus( .collect(Collectors.toList()); return MissionConverter.toMissionDtoList(missions); } + + public MemberResDTO.LoginResult login(MemberReqDTO.Login req) { + Member member = memberRepository.findByEmail(req.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + if (!passwordEncoder.matches(req.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + String accessToken = jwtUtil.createAccessToken(new AuthMember(member)); + return MemberConverter.toLoginResult(accessToken); + } + + public void signUp(MemberReqDTO.SignUp req) { + //닉네임 혹은 이메일이 이미 존재할 때 + if(memberRepository.existsByEmail(req.email())){ + throw new MemberException(MemberErrorCode.EMAIL_DUPLICATED); + } else if (memberRepository.existsByNickname(req.nickname())){ + throw new MemberException(MemberErrorCode.NICKNAME_DUPLICATED); + } + + String encodedPassword = passwordEncoder.encode(req.password()); + Member member = MemberConverter.toMember(req, encodedPassword); + memberRepository.save(member); + } } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index b784e565..240ba037 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -26,7 +26,6 @@ public class MissionController { private final MissionService missionService; - //리뷰 작성 (완료된 미션에 한해서) @PostMapping("v1/missions/{missionId}/reviews") public ResponseEntity> writeReview( diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index e78133cb..bcdfabaa 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -30,7 +30,7 @@ public ResponseEntity result = reviewService.getMemberReviewsOrderByScore(memberId, pageSize, cursor, query); + ReviewResDTO.Pagination result = reviewService.getMemberReviews(memberId, pageSize, cursor, query); return ResponseEntity .status(code.getStatus()) .body(ApiResponse.onSuccess(code, result)); diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index f6c18235..8870cd00 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -7,10 +7,8 @@ import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; import com.example.umc10th.domain.review.repository.ReviewRepository; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import java.util.List; @@ -20,40 +18,24 @@ public class ReviewService { private final ReviewRepository reviewRepository; //id 페이지네이션 - public ReviewResDTO.Pagination getMemberReviewsOrderById( + private ReviewResDTO.Pagination getMemberReviewsOrderById( Long memberId, Integer pageSize, - String cursor, - String query + String cursor ) { - - //페이지 정보 PageRequest만들기 PageRequest pageRequest = PageRequest.of(0, pageSize); - long idCursor; Slice reviewList; String nextCursor; - //커서가 있는 경우 if (!cursor.equals("-1")) { - - // 커서 분리 - String[] cursorSplit = cursor.split(":"); - switch (query.toLowerCase()) { - case "id": - idCursor = Long.parseLong(cursorSplit[1]); - // 멤버의 리뷰들 조회 & where 절에 커서값 기입 - reviewList = reviewRepository.findReviewsByMember_IdAndIdLessThanOrderByIdDesc( - memberId, - idCursor, - pageRequest - ); - break; - default: - throw new ReviewException(ReviewErrorCode.QUERY_NOT_VALID); - } + long idCursor = Long.parseLong(cursor.split(":")[1]); + reviewList = reviewRepository.findReviewsByMember_IdAndIdLessThanOrderByIdDesc( + memberId, + idCursor, + pageRequest + ); } else { - //커서 없이 조회 reviewList = reviewRepository.findReviewsByMember_IdOrderByIdDesc(memberId, pageRequest); } @@ -70,38 +52,40 @@ public ReviewResDTO.Pagination getMemberReviewsOrderById reviewList.getSize() ); } - //별점 순 페이징 - public ReviewResDTO.Pagination getMemberReviewsOrderByScore( + // query 값에 따라 정렬 전략 선택 — 컨트롤러는 이 메서드만 호출한다 + public ReviewResDTO.Pagination getMemberReviews( Long memberId, Integer pageSize, String cursor, String query + ) { + return switch (query.toLowerCase()) { + case "id" -> getMemberReviewsOrderById(memberId, pageSize, cursor); + case "score" -> getMemberReviewsOrderByScore(memberId, pageSize, cursor); + default -> throw new ReviewException(ReviewErrorCode.QUERY_NOT_VALID); + }; + } + + //별점 순 페이징 + private ReviewResDTO.Pagination getMemberReviewsOrderByScore( + Long memberId, + Integer pageSize, + String cursor ) { PageRequest pageRequest = PageRequest.of(0, pageSize); Slice reviewList; String nextCursor; - //커서가 있는 경우 + if (!cursor.equals("-1")) { - // 커서 분리 String[] cursorSplit = cursor.split(":"); - switch (query.toLowerCase()) { - case "score": - // 커서 타입 변환 - long scoreCursor = Long.parseLong(cursorSplit[0]); - long idCursor = Long.parseLong(cursorSplit[1]); - - //리뷰들 조회 & where절에 커서 값 기입 - reviewList = reviewRepository.findReviewsByScoreCursor( - memberId, - scoreCursor, - idCursor, - pageRequest); - break; - default: - throw new ReviewException(ReviewErrorCode.QUERY_NOT_VALID); - } + long scoreCursor = Long.parseLong(cursorSplit[0]); + long idCursor = Long.parseLong(cursorSplit[1]); + reviewList = reviewRepository.findReviewsByScoreCursor( + memberId, + scoreCursor, + idCursor, + pageRequest); } else { - //커서 없이 조회 reviewList = reviewRepository.findReviewsByMember_IdOrderByScoreDescIdDesc(memberId, pageRequest); } List content = reviewList.getContent(); diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java index b05e24f2..87c93a9c 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java @@ -10,9 +10,6 @@ public enum GeneralErrorCode implements BaseErrorCode{ BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400_1", "잘못된 요청입니다."), - UNAUTHORTIZED(HttpStatus.UNAUTHORIZED, - "COMMON401_1", - "인증되지 않았습니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403_1", "접근이 금지되었습니다."), @@ -29,6 +26,10 @@ public enum GeneralErrorCode implements BaseErrorCode{ "COMMON500", "서버 내부 오류입니다." ), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, + "COMMON401_2", + "승인되지 않았습니다." + ) ; private final HttpStatus status; private final String code; diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 6b8c1df0..50603137 100644 --- a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -1,23 +1,116 @@ package com.example.umc10th.global.config; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import com.example.umc10th.global.security.CustomAccessDenied; +import com.example.umc10th.global.security.CustomEntryPoint; +import com.example.umc10th.global.security.handler.OAuthSuccessHandler; +import com.example.umc10th.global.security.service.CustomOAuthService; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final CustomOAuthService customOAuthService; + + private final String[] allowUris = { + //swagger 허용 + // 자유롭게 이용할 수 있는 주소 (비로그인) + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + //로그인 + "/api/auth/**", + "/oauth/**", + }; + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain SecurityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); + // URI 허용 여부 + .authorizeHttpRequests(requests ->requests + // public API 허용 + .requestMatchers(allowUris).permitAll() + .requestMatchers("/oauth2/authorization/**").permitAll() // 로그인 진입 주소 + .requestMatchers("/oauth/callback/kakao").permitAll() // ⭐️ 카카오 콜백 주소 허용! + // 그 이외의 API는 인증 필요 + .anyRequest().authenticated() + ) + //폼 로그인 + .formLogin(AbstractHttpConfigurer::disable) + + //세션 + .sessionManagement(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + //예외 상황 핸들러 + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDenied()) + .authenticationEntryPoint(customEntryPoint()) + ) + //OAuth + .oauth2Login(oauth -> oauth + // 인증 엔트리 포인트 + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + // 콜백 주소 + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + //인증 완료 후 정보 활용 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + // 성공 시 JWT 토큰 발행할 핸들러 + .successHandler(oAuthSuccessHandler()) + ) + ; return http.build(); } + + //해시 알고리즘을 이용해 함호화된 Bcrypt 알고리즘 객체 반환 + //알고리즘은 실행때마다 매번 다른 결과물 생성 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CustomAccessDenied customAccessDenied() { + return new CustomAccessDenied(); + } + + @Bean + public CustomEntryPoint customEntryPoint() { + return new CustomEntryPoint(); + } + + private JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } } diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/entity/AuthMember.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/entity/AuthMember.java new file mode 100644 index 00000000..0a9e8f40 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/entity/AuthMember.java @@ -0,0 +1,29 @@ +package com.example.umc10th.global.entity; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(); + } + @Override + public String getPassword() { + return member.getPassword(); + } + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/filter/JwtAuthFilter.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/filter/JwtAuthFilter.java new file mode 100644 index 00000000..deaea964 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/filter/JwtAuthFilter.java @@ -0,0 +1,72 @@ +package com.example.umc10th.global.filter; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.service.CustomUserDetailsService; +import com.example.umc10th.global.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.io.IOException; +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.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.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException, java.io.IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + ObjectMapper mapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code,null); + + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java new file mode 100644 index 00000000..fc4ad57b --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDenied.java @@ -0,0 +1,21 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +public class CustomAccessDenied implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + SecurityResponseUtil.writeErrorResponse(response, GeneralErrorCode.FORBIDDEN); + } +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java new file mode 100644 index 00000000..60e7713b --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/CustomEntryPoint.java @@ -0,0 +1,21 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class CustomEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + SecurityResponseUtil.writeErrorResponse(response, GeneralErrorCode.UNAUTHORIZED); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseUtil.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseUtil.java new file mode 100644 index 00000000..251ca496 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/SecurityResponseUtil.java @@ -0,0 +1,19 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class SecurityResponseUtil { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static void writeErrorResponse(HttpServletResponse response, BaseErrorCode code) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + objectMapper.writeValue(response.getOutputStream(), ApiResponse.onFailure(code, null)); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..d1dcdbfd --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.dto; + + +import lombok.RequiredArgsConstructor; + +//카카오용 DTO +@RequiredArgsConstructor +public class KakaoDTO implements OAuthDTO { + private final String id; + private final String name; + private final String email; + + @Override + public SocialType getSocialType(){ + return SocialType.KAKAO; + } + + @Override + public String getSocialUid(){ + return id; + } + + @Override + public String getSocialEmail(){ + return email; + } + + @Override + public String getName(){ + return name; + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..dd9aefe5 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java @@ -0,0 +1,10 @@ +package com.example.umc10th.global.security.dto; + + +//공통 DTO (OAuth 제공자에 따라 정보가 다를 것을 대비한 OAuthDTO) +public interface OAuthDTO { + SocialType getSocialType(); + String getSocialUid(); + String getSocialEmail(); + String getName(); +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/SocialType.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/SocialType.java new file mode 100644 index 00000000..7b94aaca --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/dto/SocialType.java @@ -0,0 +1,5 @@ +package com.example.umc10th.global.security.dto; + +public enum SocialType { + KAKAO +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java new file mode 100644 index 00000000..4eb2018f --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -0,0 +1,29 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(); + } + @Override + public String getPassword() { + return null; + } + @Override + public String getUsername() { + return member.getSocialUid(); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..2998ef75 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java @@ -0,0 +1,37 @@ +package com.example.umc10th.global.security.entity; + + +import com.example.umc10th.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; + +//OAuth 작업을 위한 객체 +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + @Getter + 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.getSocialUid(); + } + +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..5ae3d288 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,75 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.security.dto.SocialType; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.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.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.web.filter.OncePerRequestFilter; + +import java.io.IOException; +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String uid = jwtUtil.getUid(token); + SocialType socialType = jwtUtil.getSocialType(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails member = customUserDetailsService.loadUserByUidAndSocialType(socialType, uid); + Authentication auth = new UsernamePasswordAuthenticationToken( + member, + null, + member.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + ObjectMapper mapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code,null); + + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..61e01d70 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,58 @@ +package com.example.umc10th.global.security.handler; + + +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.entity.OAuthMember; +import com.example.umc10th.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.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import java.io.IOException; + +//사용자 정보를 가져오고 서비스가 실행된 다음 실행되는 핸들러 +@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.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..44817c03 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java @@ -0,0 +1,71 @@ +package com.example.umc10th.global.security.service; + + +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.dto.KakaoDTO; +import com.example.umc10th.global.security.dto.OAuthDTO; +import com.example.umc10th.global.security.dto.SocialType; +import com.example.umc10th.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; + +import java.util.Map; + +//OAuth 과정 중 사용자 정보를 가져온 다음 실행되는 서비스 +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @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((Long) oAuthMember.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // OAuth 공통 정보 DTO로 매핑 + OAuthDTO dto; + switch (providerId) { + case KAKAO -> { + Map kakaoAccount = oAuthMember.getAttribute("kakao_account"); + if (kakaoAccount == null) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + Map profile = (Map) kakaoAccount.get("profile"); + String email = kakaoAccount.get("email") != null ? kakaoAccount.get("email").toString() : ""; + String name = (profile != null && profile.get("nickname") != null) ? profile.get("nickname").toString() : ""; + dto = new KakaoDTO(socialUid, name, email); + } + default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // DB 저장: 있다면 그 데이터 가져오고 없으면 새로 저장 + Member member = memberRepository.findBySocialTypeAndSocialUid(providerId, socialUid) + .orElseGet(() -> { + Member newMember = MemberConverter.toMember(dto); + memberRepository.save(newMember); + return newMember; + }); + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..e8c3e7b8 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.dto.SocialType; +import com.example.umc10th.global.security.entity.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService{ + + private final MemberRepository memberRepository; + + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, + String username + ) throws UsernameNotFoundException{ + Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, username) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + return new AuthMember(member); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 00000000..7dcd97c6 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,109 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.global.security.dto.SocialType; +import com.example.umc10th.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 subject에 저장된 유저 식별자를 반환합니다 + */ + public String getUid(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + /** 토큰에서 소셜 로그인 타입 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 소셜 로그인타입을 토큰에서 추출합니다 + */ + public SocialType getSocialType(String token) { + try { + return SocialType.valueOf(getClaims(token).getPayload().get("social_type").toString().toUpperCase()); // 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("social_type", member.getMember().getSocialType()) + .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); + } + + +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/service/CustomUserDetailsService.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/service/CustomUserDetailsService.java new file mode 100644 index 00000000..173be810 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/service/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.entity.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + return new AuthMember(member); + } +} diff --git a/Gibeom/umc10th/src/main/java/com/example/umc10th/global/util/JwtUtil.java b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/util/JwtUtil.java new file mode 100644 index 00000000..48c2c1c5 --- /dev/null +++ b/Gibeom/umc10th/src/main/java/com/example/umc10th/global/util/JwtUtil.java @@ -0,0 +1,93 @@ +package com.example.umc10th.global.util; + +import com.example.umc10th.global.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); + } +} \ No newline at end of file diff --git a/Gibeom/umc10th/src/main/resources/application.yml b/Gibeom/umc10th/src/main/resources/application.yml index f7681ea6..e8dc9d77 100644 --- a/Gibeom/umc10th/src/main/resources/application.yml +++ b/Gibeom/umc10th/src/main/resources/application.yml @@ -3,17 +3,40 @@ spring: name: "umc10th" # "umc10th" datasource: - driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC ???? ??? ?? + driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC url: ${DB_URL} # jdbc:mysql://localhost:3306/{???????} - username: ${DB_USER} # MySQL ?? ?? - password: ${DB_PW} # MySQL ???? + username: ${DB_USER} # MySQL + password: ${DB_PW} # MySQL jpa: - database: mysql # ??? ?????? ?? ?? (MySQL) + database: mysql database-platform: org.hibernate.dialect.MySQLDialect # Hibernate?? ??? MySQL ??(dialect) ?? - show-sql: true # ??? SQL ??? ??? ???? ?? ?? + show-sql: true hibernate: - ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? + ddl-auto: update properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "http://localhost:8080/oauth/callback/kakao" + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + userNameAttribute: id +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 #30분 \ No newline at end of file