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 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 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); + } +}