From 5de87694608f1d0d4c5d4a402147344cbf9f686c Mon Sep 17 00:00:00 2001 From: Eunseo Kim Date: Mon, 1 Jun 2026 01:14:40 +0900 Subject: [PATCH] mission/#9 --- build.gradle | 9 ++ .../member/controller/MemberController.java | 21 +++- .../member/converter/MemberConverter.java | 25 +++++ .../domain/member/dto/MemberReqDTO.java | 6 + .../domain/member/dto/MemberResDTO.java | 7 +- .../exception/code/MemberErrorCode.java | 4 +- .../exception/code/MemberSuccessCode.java | 7 ++ .../member/repository/MemberRepository.java | 2 + .../domain/member/service/MemberService.java | 19 +++- .../umc10th/global/config/SecurityConfig.java | 61 +++++++++- .../umc10th/global/security/dto/KakaoDTO.java | 32 ++++++ .../umc10th/global/security/dto/OAuthDTO.java | 10 ++ .../global/security/entity/AuthMember.java | 4 +- .../global/security/entity/OAuthMember.java | 34 ++++++ .../global/security/filter/JwtAuthFilter.java | 78 +++++++++++++ .../security/handler/OAuthSuccessHandler.java | 56 ++++++++++ .../security/service/CustomOAuthService.java | 69 ++++++++++++ .../service/CustomUserDetailsService.java | 15 ++- .../umc10th/global/security/util/JwtUtil.java | 105 ++++++++++++++++++ 19 files changed, 543 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java create mode 100644 src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java create mode 100644 src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java create mode 100644 src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java create mode 100644 src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java create mode 100644 src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java create mode 100644 src/main/java/com/example/umc10th/global/security/util/JwtUtil.java diff --git a/build.gradle b/build.gradle index d803a8d..65f34af 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,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/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 3270d4b..a7e843d 100644 --- a/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -7,7 +7,9 @@ import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; import com.example.umc10th.global.apiPayload.ApiResponse; import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -17,7 +19,18 @@ public class MemberController { private final MemberService memberService; - // 1) 회원가입 + // 1) 로그인 + @PostMapping("/auth/login") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.Login dto + ) { + return ApiResponse.onSuccess( + MemberSuccessCode.MEMBER_LOGIN_SUCCESS, + memberService.login(dto) + ); + } + + // 2) 회원가입 @PostMapping("/auth/signup") public ApiResponse signUp( @RequestBody @Valid MemberReqDTO.SignUp dto @@ -43,10 +56,12 @@ public ApiResponse getHome( // 3) 마이페이지 조회 @GetMapping("/members/me") - public ApiResponse getMyPage() { + public ApiResponse getMyPage( + @AuthenticationPrincipal AuthMember member + ) { return ApiResponse.onSuccess( MemberSuccessCode.MEMBER_GET_INFO_SUCCESS, - memberService.getMyPage() + memberService.getMyPage(member) ); } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 441219b..f8a96b1 100644 --- a/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -2,12 +2,37 @@ import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.Gender; +import com.example.umc10th.global.enums.Address; +import com.example.umc10th.global.security.dto.OAuthDTO; + +import java.time.LocalDate; public class MemberConverter { private MemberConverter() { } + public static MemberResDTO.Login toLogin(String accessToken) { + return new MemberResDTO.Login(accessToken); + } + + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.getName()) + .nickname(dto.getName()) + .email(dto.getSocialEmail()) + .password("") + .socialUid(dto.getSocialUid()) + .socialType(dto.getSocialType()) + .gender(Gender.NONE) + .birth(LocalDate.of(1900, 1, 1)) + .address(Address.NONE) + .detailAddress("") + .point(0) + .build(); + } + public static MemberResDTO.SignUp toSignUpResponse( Long memberId, String nickname, diff --git a/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index 95259f2..dd5c9b3 100644 --- a/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -10,6 +10,12 @@ public class MemberReqDTO { + // 로그인 + public record Login( + @NotBlank(message = "이메일은 필수입니다.") @Email(message = "이메일 형식이 올바르지 않습니다.") String email, + @NotBlank(message = "비밀번호는 필수입니다.") String password + ) {} + // 회원가입 public record SignUp( @NotBlank(message = "이름은 필수입니다.") String name, diff --git a/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index 7924339..ac4c288 100644 --- a/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -5,7 +5,12 @@ public class MemberResDTO { - // 1) 회원가입 응답 + // 1) 로그인 응답 + public record Login( + String accessToken + ) {} + + // 2) 회원가입 응답 public record SignUp( Long memberId, String nickname, diff --git a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index b03b957..f76d156 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -11,7 +11,9 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "존재하지 않는 회원입니다."), MEMBER_EMAIL_DUPLICATED(HttpStatus.CONFLICT, "MEMBER409_1", "이미 사용 중인 이메일입니다."), - MEMBER_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "MEMBER400_1", "잘못된 회원 요청입니다."); + MEMBER_INVALID_REQUEST(HttpStatus.BAD_REQUEST, "MEMBER400_1", "잘못된 회원 요청입니다."), + MEMBER_PASSWORD_INVALID(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "비밀번호가 올바르지 않습니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, "MEMBER400_2", "지원하지 않는 소셜 로그인입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index 1ac3c57..386f0c3 100644 --- a/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -20,6 +20,13 @@ public enum MemberSuccessCode implements BaseSuccessCode { HttpStatus.OK, "MEMBER200_2", "성공적으로 회원가입이 완료되었습니다." + ), + + // 로그인 성공 + MEMBER_LOGIN_SUCCESS( + HttpStatus.OK, + "MEMBER200_3", + "성공적으로 로그인이 완료되었습니다." ); diff --git a/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index ab0b082..a2aa4ee 100644 --- a/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,6 +1,7 @@ package com.example.umc10th.domain.member.repository; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -9,4 +10,5 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + Optional findBySocialTypeAndSocialUid(SocialType socialType, String socialUid); } diff --git a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index e86c67e..ee4a4f9 100644 --- a/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -24,6 +24,8 @@ import com.example.umc10th.domain.mission.repository.MemberMissionRepository; import com.example.umc10th.domain.mission.repository.MissionRepository; import com.example.umc10th.global.enums.Address; +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.data.domain.Page; @@ -48,11 +50,22 @@ public class MemberService { private final MemberMissionRepository memberMissionRepository; private final MissionRepository missionRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; - public MemberResDTO.MyPage getMyPage() { - Member member = memberRepository.findById(CURRENT_MEMBER_ID) + public MemberResDTO.Login login(MemberReqDTO.Login dto) { + Member member = memberRepository.findByEmail(dto.email()) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - return MemberConverter.toMyPageResponse(member); + + if (!passwordEncoder.matches(dto.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.MEMBER_PASSWORD_INVALID); + } + + String token = jwtUtil.createAccessToken(new AuthMember(member)); + return new MemberResDTO.Login(token); + } + + public MemberResDTO.MyPage getMyPage(AuthMember authMember) { + return MemberConverter.toMyPageResponse(authMember.getMember()); } @Transactional diff --git a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java index 2f480e1..c114850 100644 --- a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -1,7 +1,13 @@ package com.example.umc10th.global.config; +import com.example.umc10th.global.security.filter.JwtAuthFilter; import com.example.umc10th.global.security.handler.CustomAccessDeniedHandler; import com.example.umc10th.global.security.handler.CustomAuthenticationEntryPoint; +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; @@ -10,38 +16,83 @@ 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/**", "/swagger-resources/**", "/v3/api-docs/**", // 회원가입, 로그인 허용 - "/api/v1/auth/**" + "/api/v1/auth/**", + + // OAuth 허용 + "/oauth/**" }; + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + // URI 허용 여부 .authorizeHttpRequests(requests -> requests + // Public API 허용 여부 .requestMatchers(allowUris).permitAll() + // 그 이외 API는 인증 필요 .anyRequest().authenticated() ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() + // 폼 로그인 + .formLogin(AbstractHttpConfigurer::disable) + // 세션 (OAuth2 흐름에서 state 검증을 위해 세션 필요 - 비활성화하면 콜백 실패) + // .sessionManagement(AbstractHttpConfigurer::disable) + // JWT 필터 + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + // OAuth + .oauth2Login(oauth -> oauth + + // 인증 엔트리 포인트 + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + + // 콜백 주소 + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + + // 인증 완료 후 정보 활용 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + + // 성공 시 JWT 토큰 발행 핸들러 + .successHandler(oAuthSuccessHandler()) ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ) - // 에러 상황 핸들러 + // 예외 상황 핸들러 .exceptionHandling(exception -> exception .accessDeniedHandler(customAccessDeniedHandler()) .authenticationEntryPoint(customAuthenticationEntryPoint()) diff --git a/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java b/src/main/java/com/example/umc10th/global/security/dto/KakaoDTO.java new file mode 100644 index 0000000..0743b4d --- /dev/null +++ b/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.domain.member.enums.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/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java b/src/main/java/com/example/umc10th/global/security/dto/OAuthDTO.java new file mode 100644 index 0000000..144f4c5 --- /dev/null +++ b/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.domain.member.enums.SocialType; + +public interface OAuthDTO { + SocialType getSocialType(); + String getSocialUid(); + String getSocialEmail(); + String getName(); +} diff --git a/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java index 3621047..f03316b 100644 --- a/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java +++ b/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -23,11 +23,11 @@ public Collection getAuthorities() { @Override public @Nullable String getPassword() { - return member.getPassword(); + return null; } @Override public String getUsername() { - return member.getEmail(); + return member.getSocialUid(); } } \ No newline at end of file diff --git a/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java new file mode 100644 index 0000000..1af7b5a --- /dev/null +++ b/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/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 0000000..d5c9d02 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,78 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.domain.member.enums.SocialType; +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.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 user = customUserDetailsService.loadUserByUidAndSocialType(socialType, uid); + 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/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 0000000..96ae8da --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,56 @@ +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.MEMBER_LOGIN_SUCCESS; + + // 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 추출 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java new file mode 100644 index 0000000..23e0ee3 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java @@ -0,0 +1,69 @@ +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.enums.SocialType; +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 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; + +@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()); + Object id = oAuthMember.getAttribute("id"); + socialUid = id != null ? id.toString() : null; + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + Map attributes = oAuthMember.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + + // OAuth 공통 정보 DTO로 매핑 + OAuthDTO dto; + switch (providerId) { + case KAKAO -> { + String email = attributes.get("email") != null ? attributes.get("email").toString() : ""; + String name = profile.get("nickname").toString(); + 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()); + } +} diff --git a/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java index e2cf267..bade486 100644 --- a/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -1,27 +1,30 @@ package com.example.umc10th.global.security.service; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; +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.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 { +public class CustomUserDetailsService { private final MemberRepository memberRepository; - @Override - public UserDetails loadUserByUsername( + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, String username ) throws UsernameNotFoundException { - Member member = memberRepository.findByEmail(username) - .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다.")); + // DB에서 기존 회원 정보 조회 & 인증 객체 생성 + Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); return new AuthMember(member); } diff --git a/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 0000000..083c545 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,105 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.domain.member.enums.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); + } + + + 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() + ); + } 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 UID를 Subject로 + .claim("role", authorities) + .claim("social_type", member.getMember().getSocialType()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(secretKey) + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file