diff --git a/Jinyong/build.gradle b/Jinyong/build.gradle
index d4404db0..ca05b025 100644
--- a/Jinyong/build.gradle
+++ b/Jinyong/build.gradle
@@ -38,6 +38,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/Jinyong/keyword_summary/ch09.md b/Jinyong/keyword_summary/ch09.md
new file mode 100644
index 00000000..7fe555ce
--- /dev/null
+++ b/Jinyong/keyword_summary/ch09.md
@@ -0,0 +1,95 @@
+- 세션과 토큰의 차이는?
+
+ **세션 기반 인증**: 서버가 사용자의 로그인 상태를 기억하는 방식
+
+ → 사용자가 로그인 하면 서버는 세션 생성
+
+ 브라우저에 JSESSIONID 같은 세션 ID가 담긴 쿠키 전달
+
+ 이후 사용자가 요청을 보낼 때마다 쿠키가 함께 전송되고, 서버는 그 세션 ID를 보고 “이 사용자는 로그인한 사용자!”라고 판단함
+
+ **토큰 기반 인증**: 서버가 로그인 상태를 직접 저장하지 않음. 클라이언트가 토큰을 들고 다니는 방식임
+
+ → 로그인에 성공하면 서버가 JWT 같은 토큰 발급
+
+ 클라이언트는 이후 요청마다 `Authorization: Bearer 토큰값` 형태로 토큰을 보냄
+
+ → 서버는 토큰의 서명과 만료 시간 검증해 사용자 인증
+
+ | 구분 | 세션 방식 | 토큰 방식 |
+ | --- | --- | --- |
+ | 저장 위치 | 서버가 세션 저장 | 클라이언트가 토큰 저장 |
+ | 서버 상태 | Stateful | Stateless |
+ | 요청 방식 | 쿠키 기반 | Authorization Header 기반 |
+ | 장점 | 서버에서 세션 삭제 가능, 제어 쉬움 | 서버 확장에 유리 |
+ | 단점 | 서버가 세션을 관리해야 함 | 토큰 탈취 시 만료 전까지 위험 |
+ | 예시 | 전통적인 웹 로그인 | JWT 기반 REST API 로그인 |
+
+ 최근에는 토큰 방식으로 로그인을 받는 추세이지만, 보안이 중요한 금융쪽이나 몇몇 분야는 세션방식을 고집한다.
+
+- 엑세스 토큰과 리프레시 토큰이란?
+
+ **Access Token(액세스 토큰)**은 사용자가 보호된 API에 접근할 때 사용하는 실제 인증 토큰이다.
+
+ Ex) 마이페이지 조회, 리뷰 작성, 미션 등록 같은 private API 호출할 때 클라이언트는 Access Token을 `Authorization` 헤더에 담아 보낸다.
+
+
+
+ 서버는 토큰을 검증 후 정상이면 사용자를 인증 사용자로 처리한다.
+
+
+
+ **Refresh Token(리프레시 토큰)은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위한 토큰이**다.
+
+ → API 요청에 직접 사용하는 토큰이 아니라 재발급용 토큰
+
+ Access Token은 보통 탈취 위험을 줄이기 위해 짧게 유지한다(30분~2시간)
+
+ 하지만 Access Token이 너무 자주 만료되면 사용자는 계속 다시 로그인해야
+
+ 하므로 불편함!
+
+ → 이 문제를 해결하기 위해 Refresh Token을 사용하는 것이다.
+
+ | 구분 | Access Token | Refresh Token |
+ | --- | --- | --- |
+ | 목적 | API 접근 | Access Token 재발급 |
+ | 사용 위치 | 매 요청마다 사용 | 토큰 재발급 시 사용 |
+ | 만료 시간 | 짧게 설정 | 상대적으로 길게 설정 |
+ | 노출 위험 | 자주 전송되기에 탈취 위험 있음 | 더 오래 유효하기 때문에 안전하게 보관 필요 |
+ | 예시 | 마이페이지 요청, 리뷰 작성 | Access Token 재발급 요청 |
+
+ Access Token은 “출입증”에 가깝고, Refresh Token은 “출입증을 다시 발급받을 수 있는 재발급 권한”에 가깝다.
+
+- OAuth 1.0과 OAuth 2.0의 차이는?
+
+ OAuth는 사용자의 비밀번호를 직접 공유하지 않고, 제3자 애플리케이션이 사용자의 리소스에 제한적으로 접근할 수 있게 해주는 권한 위임 프로토콜이다.
+
+ 쉽게 말하면, “카카오로 로그인”, “구글로 로그인” 같은 기능이 OAuth 기반으로 동작하는 것이다.
+
+ 우리 서비스가 사용자의 카카오 비밀번호를 직접 받는 것이 아니라, 카카오가 인증을 처리하고 우리 서버는 카카오로부터 사용자 정보를 받아 회원가입 또는 로그인을 처리한다.
+
+ OAuth 1.0은 초기 OAuth 프로토콜이며, 요청마다 복잡한 서명 과정을 거쳐야 하고 클라이언트가 요청을 만들 때 암호화 서명값을 포함해야 한다.
+
+ OAuth 2.0은 위의 OAuth 1.0을 대체한 방식이다. OAuth 2.0 명세는 OAuth 1.0을 대체한다고 명시하고 있으며, 현재 대부분의 소셜 로그인과 API 권한 위임에서 OAuth 2.0이 사용된다.
+
+ OAuth 2.0은 HTTPS를 전제로 하며, OAuth 1.0처럼 매 요청마다 복잡한 서명을 직접 만드는 방식보다 구현이 단순하다. 또한 웹, 모바일 앱, SPA, 서버 간 통신 등 다양한 환경에 맞는 인증 흐름을 제공한다.
+
+ | 구분 | OAuth 1.0 | OAuth 2.0 |
+ | --- | --- | --- |
+ | 등장 시기 | 초기 방식 | OAuth 1.0 대체 |
+ | 보안 방식 | 요청마다 서명 필요 | HTTPS 기반 보호 |
+ | 구현 난이도 | 비교적 복잡 | 비교적 단순 |
+ | 토큰 방식 | Access Token 중심 | Access Token, Refresh Token 등 활용 |
+ | 사용 환경 | 초기 웹 중심 | 웹, 모바일, API 서버 등 다양함 |
+ | 현재 사용성 | 거의 안 쓰는 추세 | 현재 표준 |
+
+ OAuth 1.0은 요청마다 직접 서명을 만들어야 해서 보안적으로 엄격하지만 구현이 복잡하다.
+
+ OAuth 2.0은 HTTPS를 기반으로 보안을 확보하고, Access Token과 Refresh Token을 이용해 더 단순하고 유연한 구조를 제공한다.
+
+ 따라서 현재 카카오, 구글, 네이버 로그인 같은 소셜 로그인은 대부분 OAuth 2.0 기반으로 이해하면 된다.
\ No newline at end of file
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java
index a9a39488..39c8209b 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java
@@ -1,13 +1,21 @@
package com.example.umc10th.domain.member.controller;
import com.example.umc10th.domain.member.code.MemberSuccessCode;
-import com.example.umc10th.domain.member.dto.MemberResDTO;
import com.example.umc10th.domain.member.dto.MemberReqDTO;
+import com.example.umc10th.domain.member.dto.MemberResDTO;
import com.example.umc10th.domain.member.service.MemberService;
import com.example.umc10th.global.apiPayload.ApiResponse;
import com.example.umc10th.global.apiPayload.code.BaseSuccessCode;
+import com.example.umc10th.global.security.entity.AuthMember;
import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@@ -16,21 +24,35 @@ public class MemberController {
private final MemberService memberService;
- // 마이페이지
@GetMapping("/v1/users/me")
- public ApiResponse getInfo(
- @RequestParam Long memberId
+ public ResponseEntity> getInfo(
+ @AuthenticationPrincipal AuthMember authMember
) {
BaseSuccessCode code = MemberSuccessCode.OK;
- return ApiResponse.onSuccess(code, memberService.getInfo(memberId));
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ApiResponse.onSuccess(code, memberService.getInfo(authMember)));
}
// 회원가입
@PostMapping("/v1/auth/signup")
- public ApiResponse signUp(
+ public ResponseEntity> signUp(
@RequestBody MemberReqDTO.SignUp request
) {
BaseSuccessCode code = MemberSuccessCode.CREATED;
- return ApiResponse.onSuccess(code, memberService.signUp(request));
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(ApiResponse.onSuccess(code, memberService.signUp(request)));
+ }
+
+ // 로그인
+ @PostMapping("/v1/auth/login")
+ public ResponseEntity> login(
+ @RequestBody MemberReqDTO.Login request
+ ) {
+ BaseSuccessCode code = MemberSuccessCode.OK;
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ApiResponse.onSuccess(code, memberService.login(request)));
}
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java
index 3745b129..52229a26 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java
@@ -4,6 +4,7 @@
import com.example.umc10th.domain.member.dto.MemberResDTO;
import com.example.umc10th.domain.member.entity.Gender;
import com.example.umc10th.domain.member.entity.Member;
+import com.example.umc10th.global.security.dto.OAuthDTO;
import java.time.LocalDate;
@@ -13,14 +14,14 @@ public class MemberConverter {
public static MemberResDTO.GetInfo toGetInfo(Member member) {
// 빌더 패턴을 사용하여 엔티티의 데이터를 DTO에 매핑합니다.
- return MemberResDTO.GetInfo.builder()
- .email(member.getEmail())
- .name(member.getName())
- .point(member.getPoint())
- .nickname(member.getNickname())
- .gender(member.getGender().name())
- .phoneNumber(null)
- .build();
+ return new MemberResDTO.GetInfo(
+ member.getName(),
+ member.getEmail(),
+ null,
+ member.getPoint(),
+ member.getNickname(),
+ member.getGender().name()
+ );
}
public static Member toMember(MemberReqDTO.SignUp request, String encodedPassword) {
@@ -35,10 +36,31 @@ public static Member toMember(MemberReqDTO.SignUp request, String encodedPasswor
.build();
}
- public static MemberResDTO.SignUp toSignUp(Member member) {
- return MemberResDTO.SignUp.builder()
- .memberId(member.getId())
- .email(member.getEmail())
+ // 회원가입
+ public static Member toMember(OAuthDTO dto) {
+ return Member.builder()
+ .email(dto.getSocialEmail())
+ .password("")
+ .name(dto.getName())
+ .nickname(dto.getName())
+ .gender(Gender.NONE)
+ .point(0)
+ .birth(LocalDate.now())
+ .socialType(dto.getSocialType())
+ .socialUid(dto.getSocialUid())
.build();
}
+
+ public static MemberResDTO.SignUp toSignUp(Member member) {
+ return new MemberResDTO.SignUp(
+ member.getId(),
+ member.getEmail()
+ );
+ }
+
+
+ // 로그인
+ public static MemberResDTO.Login toLogin(String accessToken) {
+ return new MemberResDTO.Login(accessToken);
+ }
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java
index 1aaead74..aa7b18d4 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java
@@ -6,8 +6,15 @@ public record GetInfo(
Long id
){}
+ // 회원가입
public record SignUp(
String email,
String password
){}
+
+ // 로그인
+ public record Login(
+ String email,
+ String password
+ ){}
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java
index f183d7cc..3eef86f6 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java
@@ -13,9 +13,16 @@ public record GetInfo(
String gender
) {}
+ // 회원가입
@Builder
public record SignUp(
Long memberId,
String email
) {}
+
+ // 로그인
+ @Builder
+ public record Login(
+ String accessToken
+ ) {}
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/entity/Member.java
index 3c267c60..6319fcf9 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/entity/Member.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/entity/Member.java
@@ -1,6 +1,7 @@
package com.example.umc10th.domain.member.entity;
import com.example.umc10th.domain.BaseEntity;
+import com.example.umc10th.global.security.entity.SocialType;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
@@ -38,4 +39,9 @@ public class Member extends BaseEntity {
@Column(nullable = false)
private LocalDate birth;
-}
\ No newline at end of file
+
+ @Enumerated(EnumType.STRING)
+ private SocialType socialType;
+
+ private String socialUid;
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java
index c5a98d5f..cdc088f4 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java
@@ -1,12 +1,8 @@
package com.example.umc10th.domain.member.exception.code;
import com.example.umc10th.global.apiPayload.code.BaseErrorCode;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
import org.springframework.http.HttpStatus;
-@Getter
-@AllArgsConstructor
public enum MemberErrorCode implements BaseErrorCode {
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,
@@ -14,14 +10,36 @@ public enum MemberErrorCode implements BaseErrorCode {
"해당 사용자를 찾을 수 없습니다."),
MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT,
"MEMBER409_1",
- "이미 존재하는 이메일입니다.");
+ "이미 존재하는 이메일입니다."),
+ INVALID_PASSWORD(HttpStatus.UNAUTHORIZED,
+ "MEMBER401_1",
+ "비밀번호가 일치하지 않습니다."),
+ NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST,
+ "MEMBER400_1",
+ "지원하지 않는 소셜 로그인 제공자입니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
+ MemberErrorCode(HttpStatus httpStatus, String code, String message) {
+ this.httpStatus = httpStatus;
+ this.code = code;
+ this.message = message;
+ }
+
@Override
public HttpStatus getStatus() {
return httpStatus;
}
-}
+
+ @Override
+ public String getCode() {
+ return code;
+ }
+
+ @Override
+ public String getMessage() {
+ return message;
+ }
+}
\ No newline at end of file
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java
index 0b676788..f83523b9 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java
@@ -1,10 +1,12 @@
package com.example.umc10th.domain.member.repository;
import com.example.umc10th.domain.member.entity.Member;
+import com.example.umc10th.global.security.entity.SocialType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository {
Optional findByEmail(String email);
+ Optional findBySocialTypeAndSocialUid(SocialType socialType, String socialUid);
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java
index e1645ddc..2e088f11 100644
--- a/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java
+++ b/Jinyong/src/main/java/com/example/umc10th/domain/member/service/MemberService.java
@@ -7,6 +7,8 @@
import com.example.umc10th.domain.member.dto.MemberResDTO;
import com.example.umc10th.domain.member.exception.MemberException;
import com.example.umc10th.domain.member.repository.MemberRepository;
+import com.example.umc10th.global.security.entity.AuthMember;
+import com.example.umc10th.global.security.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -17,14 +19,17 @@ public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
-
- public MemberResDTO.GetInfo getInfo(Long memberId) {
- Member member = memberRepository.findById(memberId)
- .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
-
- return MemberConverter.toGetInfo(member);
+ private final JwtUtil jwtUtil;
+
+ // 마이페이지
+ public MemberResDTO.GetInfo getInfo(
+ AuthMember member
+ ) {
+ // 컨버터를 이용해서 응답 DTO 생성 & return
+ return MemberConverter.toGetInfo(member.getMember());
}
+ // 회원가입
public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) {
memberRepository.findByEmail(request.email())
.ifPresent(member -> {
@@ -37,4 +42,17 @@ public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) {
return MemberConverter.toSignUp(savedMember);
}
+
+ // 로그인
+ public MemberResDTO.Login login(MemberReqDTO.Login request) {
+ Member member = memberRepository.findByEmail(request.email())
+ .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
+
+ if (!passwordEncoder.matches(request.password(), member.getPassword())) {
+ throw new MemberException(MemberErrorCode.INVALID_PASSWORD);
+ }
+
+ String accessToken = jwtUtil.createAccessToken(new AuthMember(member));
+ return MemberConverter.toLogin(accessToken);
+ }
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java
index 9d8e60dc..811b3693 100644
--- a/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java
+++ b/Jinyong/src/main/java/com/example/umc10th/global/config/SecurityConfig.java
@@ -1,20 +1,34 @@
package com.example.umc10th.global.config;
+import com.example.umc10th.global.security.filter.JwtAuthFilter;
import com.example.umc10th.global.security.handler.CustomAccessDenied;
import com.example.umc10th.global.security.handler.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.config.http.SessionCreationPolicy;
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; // 추가됨!
@EnableWebSecurity
@Configuration
+@RequiredArgsConstructor // 의존성 주입을 위해 추가됨
public class SecurityConfig {
+ // 필터에 넣어줄 기술 도구들 주입받기
+ private final JwtUtil jwtUtil;
+ private final CustomUserDetailsService customUserDetailsService;
+ private final CustomOAuthService customOAuthService;
+
private final String[] allowUris = {
// Swagger
"/swagger-ui/**",
@@ -22,7 +36,17 @@ public class SecurityConfig {
"/v3/api-docs/**",
// 회원가입
- "/api/v1/auth/signup"
+ "/api/v1/auth/signup",
+
+ // 로그인
+ "/api/v1/auth/login",
+
+ // OAuth
+ "/oauth/authorize/**",
+ "/oauth/callback/**",
+ "/login",
+ "/login/**",
+ "/error"
};
@Bean
@@ -33,10 +57,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
- .formLogin(form -> form
- .defaultSuccessUrl("/swagger-ui/index.html", true)
- .permitAll()
+ // 1. 기존 폼 로그인 기능을 끈다
+ .formLogin(AbstractHttpConfigurer::disable)
+
+ // 2. 세션을 쓰지 않으므로 Stateless(무상태)로 설정하며 끈다.
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
+
+ // 3. 기존(UsernamePassword...) 바로 앞에 JWT 배치
+ .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
+
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
@@ -45,8 +76,38 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDenied())
.authenticationEntryPoint(customEntryPoint())
- );
+ )
+ // OAuth
+ .oauth2Login(oauth -> oauth
+ // 인증 엔트리 포인트
+ .authorizationEndpoint(auth -> auth
+ .baseUri("/oauth/authorize")
+ )
+ // 콜백 주소
+ .redirectionEndpoint(redirect -> redirect
+ .baseUri("/oauth/callback/kakao")
+ )
+ // 인증 완료 후 정보 활용
+ .userInfoEndpoint(userInfo -> userInfo
+ .userService(customOAuthService)
+ )
+ // 성공 시 JWT 토큰 발행할 핸들러
+ .successHandler(oAuthSuccessHandler())
+ .failureHandler((request, response, exception) -> {
+ exception.printStackTrace();
+
+ response.setContentType("application/json;charset=UTF-8");
+ response.setStatus(401);
+ response.getWriter().write("""
+ {
+ "isSuccess": false,
+ "message": "OAuth 실패",
+ "error": "%s"
+ }
+ """.formatted(exception.getMessage()));
+ })
+ );
return http.build();
}
@@ -64,4 +125,15 @@ public CustomAccessDenied customAccessDenied() {
public CustomEntryPoint customEntryPoint() {
return new CustomEntryPoint();
}
+
+ // 주입받은 것들은 Filter를 만들 때 넣어줌
+ @Bean
+ public JwtAuthFilter jwtAuthFilter() {
+ return new JwtAuthFilter(jwtUtil, customUserDetailsService);
+ }
+
+ @Bean
+ public OAuthSuccessHandler oAuthSuccessHandler() {
+ return new OAuthSuccessHandler(jwtUtil);
+ }
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java b/Jinyong/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java
new file mode 100644
index 00000000..7e932521
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java
@@ -0,0 +1,32 @@
+package com.example.umc10th.global.security.dto;
+
+import com.example.umc10th.global.security.entity.SocialType;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class KakaoDTO implements OAuthDTO {
+
+ private final String id;
+ private final String email;
+ private final String name;
+
+ @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/Jinyong/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java b/Jinyong/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java
new file mode 100644
index 00000000..74c6b051
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java
@@ -0,0 +1,10 @@
+package com.example.umc10th.global.security.dto;
+
+import com.example.umc10th.global.security.entity.SocialType;
+
+public interface OAuthDTO {
+ SocialType getSocialType();
+ String getSocialUid();
+ String getSocialEmail();
+ String getName();
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java
index 61a32f48..7d94e9ef 100644
--- a/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java
@@ -28,6 +28,9 @@ public Collection extends GrantedAuthority> getAuthorities() {
@Override
public String getUsername() {
+ if (member.getSocialType() != null) {
+ return member.getSocialUid();
+ }
return member.getEmail();
}
}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java
new file mode 100644
index 00000000..1af7b5a6
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java
@@ -0,0 +1,34 @@
+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;
+
+@RequiredArgsConstructor
+public class OAuthMember implements OAuth2User {
+
+ @Getter
+ private final Member member;
+ private final Map attributes;
+
+ @Override
+ public Map getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return List.of();
+ }
+
+ @Override
+ public String getName() {
+ return member.getSocialUid();
+ }
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/entity/SocialType.java b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/SocialType.java
new file mode 100644
index 00000000..aea6da33
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/entity/SocialType.java
@@ -0,0 +1,5 @@
+package com.example.umc10th.global.security.entity;
+
+public enum SocialType {
+ KAKAO
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/Jinyong/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java
new file mode 100644
index 00000000..937a73c2
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java
@@ -0,0 +1,79 @@
+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.entity.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)) {
+
+ // JWT 토큰에서 유저 정보 조회: UID와 소셜 로그인 타입 가져오기
+ String uid = jwtUtil.getUid(token);
+ SocialType socialType = jwtUtil.getSocialType(token);
+
+ // 인증 객체 생성: 로그인 타입과 UID로 찾아온 뒤, 인증 객체 생성
+ 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);
+ }
+ }
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java
new file mode 100644
index 00000000..3f095713
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java
@@ -0,0 +1,59 @@
+package com.example.umc10th.global.security.handler;
+
+import com.example.umc10th.domain.member.converter.MemberConverter;
+import com.example.umc10th.domain.member.code.MemberSuccessCode;
+import com.example.umc10th.domain.member.dto.MemberResDTO;
+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.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 인증 객체 가져오기
+ Object principal = authentication.getPrincipal();
+ if (!(principal instanceof OAuthMember member)) {
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid OAuth principal.");
+ return;
+ }
+
+ // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작
+ String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember()));
+
+ // 응답 통일 객체 래핑
+ ApiResponse responseBody = ApiResponse.onSuccess(
+ code,
+ MemberConverter.toLogin(accessToken)
+ );
+
+ // 응답 출력
+ objectMapper.writeValue(response.getOutputStream(), responseBody);
+ }
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java
new file mode 100644
index 00000000..0554467e
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java
@@ -0,0 +1,89 @@
+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.entity.OAuthMember;
+import com.example.umc10th.global.security.entity.SocialType;
+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.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@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 = getRequiredString(oAuthMember.getAttributes(), "id");
+ } catch (IllegalArgumentException e) {
+ throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER);
+ }
+
+ // OAuth 공통 정보 DTO로 매핑
+ OAuthDTO dto;
+ switch (providerId) {
+ case KAKAO -> {
+ Map attributes = getRequiredMap(oAuthMember.getAttributes(), "kakao_account");
+ Map profile = getRequiredMap(attributes, "profile");
+ String email = getRequiredString(attributes, "email");
+ String name = getRequiredString(profile, "nickname");
+ dto = new KakaoDTO(socialUid, email, name);
+ }
+ 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());
+ }
+
+ private Map getRequiredMap(Map attributes, String key) {
+ Object value = attributes.get(key);
+ if (!(value instanceof Map, ?> map)) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("invalid_kakao_response"),
+ "Kakao OAuth response missing map: " + key
+ );
+ }
+ return (Map) map;
+ }
+
+ private String getRequiredString(Map attributes, String key) {
+ Object value = attributes.get(key);
+ if (value == null) {
+ throw new OAuth2AuthenticationException(
+ new OAuth2Error("invalid_kakao_response"),
+ "Kakao OAuth response missing value: " + key
+ );
+ }
+ return String.valueOf(value);
+ }
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java
index 7789bfc1..fae2fb99 100644
--- a/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java
@@ -5,6 +5,7 @@
import com.example.umc10th.domain.member.exception.code.MemberErrorCode;
import com.example.umc10th.domain.member.repository.MemberRepository;
import com.example.umc10th.global.security.entity.AuthMember;
+import com.example.umc10th.global.security.entity.SocialType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -23,6 +24,22 @@ public UserDetails loadUserByUsername(
) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
+
+ return new AuthMember(member);
+ }
+
+ public UserDetails loadUserByUidAndSocialType(
+ SocialType socialType,
+ String username
+ ) throws UsernameNotFoundException {
+ if (socialType == null) {
+ return loadUserByUsername(username);
+ }
+
+ // DB에서 기존 회원 정보 조회 & 인증 객체 생성
+ Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, username)
+ .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
+
return new AuthMember(member);
}
-}
\ No newline at end of file
+}
diff --git a/Jinyong/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/Jinyong/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java
new file mode 100644
index 00000000..84d66542
--- /dev/null
+++ b/Jinyong/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java
@@ -0,0 +1,101 @@
+package com.example.umc10th.global.security.util;
+
+import com.example.umc10th.global.security.entity.AuthMember;
+import com.example.umc10th.global.security.entity.SocialType;
+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 SocialType getSocialType(String token) {
+ try {
+ Object socialType = getClaims(token).getPayload().get("social_type");
+ if (socialType == null) {
+ return null;
+ }
+ return SocialType.valueOf(socialType.toString().toUpperCase());
+ } 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;
+ }
+ }
+
+ public String getUid(String token) {
+ return getClaims(token).getPayload().getSubject();
+ }
+
+ // 토큰 생성
+ 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);
+ }
+}