From f7b918e8368ee914a8b742030aff63a686594a74 Mon Sep 17 00:00:00 2001 From: migi0401 Date: Sun, 31 May 2026 21:44:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20&=20OAuth=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 9 ++ .../member/controller/MemberController.java | 19 ++- .../member/converter/MemberConverter.java | 22 ++++ .../umc/domain/member/dto/MemberReqDTO.java | 5 + .../umc/domain/member/dto/MemberResDTO.java | 6 + .../java/umc/domain/member/entity/Member.java | 21 ++-- .../umc/domain/member/enums/SocialType.java | 8 ++ .../exception/code/MemberErrorCode.java | 8 +- .../member/repository/MemberRepository.java | 3 + .../member/service/MemberCommandService.java | 19 +++ .../member/service/MemberQueryService.java | 13 +- .../umc/global/config/SecurityConfig.java | 46 +++++++- .../umc/global/security/dto/KakaoDTO.java | 31 +++++ .../umc/global/security/dto/OAuthDTO.java | 10 ++ .../global/security/entity/AuthMember.java | 8 +- .../global/security/entity/OAuthMember.java | 36 ++++++ .../global/security/filter/JwtAuthFilter.java | 76 ++++++++++++ .../security/handler/OAuthSuccessHandler.java | 63 ++++++++++ .../security/service/CustomOAuthService.java | 85 ++++++++++++++ .../service/CustomUserDetailsService.java | 24 +++- .../umc/global/security/util/JwtUtil.java | 111 ++++++++++++++++++ src/main/resources/application.yml | 27 ++++- 22 files changed, 624 insertions(+), 26 deletions(-) create mode 100644 src/main/java/umc/domain/member/enums/SocialType.java create mode 100644 src/main/java/umc/global/security/dto/KakaoDTO.java create mode 100644 src/main/java/umc/global/security/dto/OAuthDTO.java create mode 100644 src/main/java/umc/global/security/entity/OAuthMember.java create mode 100644 src/main/java/umc/global/security/filter/JwtAuthFilter.java create mode 100644 src/main/java/umc/global/security/handler/OAuthSuccessHandler.java create mode 100644 src/main/java/umc/global/security/service/CustomOAuthService.java create mode 100644 src/main/java/umc/global/security/util/JwtUtil.java diff --git a/build.gradle b/build.gradle index 2270151a..5bb85121 100644 --- a/build.gradle +++ b/build.gradle @@ -22,10 +22,19 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // 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' + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' + //OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/umc/domain/member/controller/MemberController.java b/src/main/java/umc/domain/member/controller/MemberController.java index 5b7a464f..0ec60e6a 100644 --- a/src/main/java/umc/domain/member/controller/MemberController.java +++ b/src/main/java/umc/domain/member/controller/MemberController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import umc.domain.member.converter.MemberConverter; import umc.domain.member.dto.MemberReqDTO; @@ -13,6 +14,7 @@ import umc.domain.member.service.MemberQueryService; import umc.global.apiPayload.ApiResponse; import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.entity.AuthMember; @RestController @RequiredArgsConstructor @@ -22,6 +24,15 @@ public class MemberController { private final MemberQueryService memberQueryService; private final MemberCommandService memberCommandService; + @PostMapping("/login") + @Operation(summary = "로그인") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.LoginReqDTO request + ){ + MemberResDTO.LoginResultDTO result = memberCommandService.login(request); + BaseSuccessCode code = MemberSuccessCode.OK; + return ApiResponse.onSuccess(code, result); + } @PostMapping("/sign-up") @Operation(summary = "회원가입") @@ -38,14 +49,10 @@ public ApiResponse join( @GetMapping("/me") @Operation(summary = "마이페이지 프로필 조회") public ApiResponse getMyProfile( - @RequestParam (name = "memberId") Long memberId + @AuthenticationPrincipal AuthMember authMember ){ BaseSuccessCode code = MemberSuccessCode.OK; - //회원 엔티티 조회 - Member member = memberQueryService.getMyProfile(memberId); - - //컨버터 이용 - MemberResDTO.MyProfileDTO result = MemberConverter.toMyProfileDTO(member); + MemberResDTO.MyProfileDTO result = memberQueryService.getMyProfile(authMember); return ApiResponse.onSuccess(code, result); } diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index 98bd2df4..4265e164 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -6,7 +6,10 @@ import umc.domain.member.entity.Member; import umc.domain.member.entity.mapping.MemberFood; import umc.domain.member.entity.mapping.MemberPolicy; +import umc.domain.member.enums.Gender; +import umc.domain.member.enums.SocialType; import umc.domain.policy.entity.Policy; +import umc.global.security.dto.OAuthDTO; import java.util.ArrayList; import java.util.List; @@ -25,6 +28,7 @@ public static MemberResDTO.MyProfileDTO toMyProfileDTO(Member member){ ); } + //일반 회원 전용 public static Member toMember(MemberReqDTO.JoinDTO request, String encodedPassword){ return Member.builder() .name(request.name()) @@ -42,6 +46,18 @@ public static Member toMember(MemberReqDTO.JoinDTO request, String encodedPasswo .build(); } + //소셜 로그인 전용 + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.getName())// 카카오는 닉네임을 주므로 이름/닉네임 동일하게 세팅 + .mail(dto.getSocialEmail()) + .socialUid(dto.getSocialUid()) + .socialType(dto.getSocialType()) // 서비스에서 넘겨준 KAKAO, NAVER 등 + // 👇 아래는 DB 필수값(nullable = false)을 통과하기 위한 더미(임시) 데이터들입니다. + .gender(Gender.NONE) + .build(); + } + public static MemberFood toMemberFood(Food food){ return MemberFood.builder() .food(food) @@ -88,4 +104,10 @@ public static MemberResDTO.JoinResultDTO toJoinResultDTO(Member member){ .createdAt(member.getCreatedAt()) .build(); } + + public static MemberResDTO.LoginResultDTO toLogin(String accessToken) { + return MemberResDTO.LoginResultDTO.builder() + .accessToken(accessToken) + .build(); + } } diff --git a/src/main/java/umc/domain/member/dto/MemberReqDTO.java b/src/main/java/umc/domain/member/dto/MemberReqDTO.java index 67a40c23..a45b0429 100644 --- a/src/main/java/umc/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberReqDTO.java @@ -29,4 +29,9 @@ public record AgreeDTO( boolean location, boolean marketing ){} + + public record LoginReqDTO( + @NotNull String mail, + @NotNull String password + ){} } diff --git a/src/main/java/umc/domain/member/dto/MemberResDTO.java b/src/main/java/umc/domain/member/dto/MemberResDTO.java index 92e2991d..a6163757 100644 --- a/src/main/java/umc/domain/member/dto/MemberResDTO.java +++ b/src/main/java/umc/domain/member/dto/MemberResDTO.java @@ -11,6 +11,12 @@ public record JoinResultDTO( Long userId, LocalDateTime createdAt ){} + //로그인 응답 + @Builder + public record LoginResultDTO( + String accessToken + ){} + //마이페이지 조회용 public record MyProfileDTO( String nickname, diff --git a/src/main/java/umc/domain/member/entity/Member.java b/src/main/java/umc/domain/member/entity/Member.java index e263bc76..ea05439f 100644 --- a/src/main/java/umc/domain/member/entity/Member.java +++ b/src/main/java/umc/domain/member/entity/Member.java @@ -9,6 +9,7 @@ import umc.domain.member.entity.mapping.MemberFood; import umc.domain.member.entity.mapping.MemberPolicy; import umc.domain.member.enums.Gender; +import umc.domain.member.enums.SocialType; import umc.domain.review.entity.Review; import umc.global.common.BaseEntity; @@ -31,7 +32,7 @@ public class Member extends BaseEntity { @Column(name = "name", nullable = false) private String name; - @Column(name = "nickname", nullable = false) + @Column(name = "nickname") private String nickname; @Column(name = "gender", nullable = false) @@ -39,33 +40,37 @@ public class Member extends BaseEntity { @Builder.Default private Gender gender = Gender.NONE; - @Column(name = "birth", nullable = false) + @Column(name = "birth") private LocalDate birth; - @Column(name = "address", nullable = false) + @Column(name = "address") private String address; @Column(name = "mail", nullable = false) private String mail; - @Column(name = "password", nullable = false) + @Column(name = "password") private String password; - @Column(name = "photo", nullable = false) + @Column(name = "photo") private String photoUrl; - @Column(name = "phoneNumber", nullable = false) + @Column(name = "phoneNumber") private String phoneNumber; - @Column(name = "userPoint", nullable = false) + @Column(name = "userPoint") private Integer userPoint; @Column(name = "social_uid", nullable = false) private String socialUid; - @Column(name = "social_provider", nullable = false) + @Column(name = "social_provider") private String socialProvider; + @Enumerated(EnumType.STRING) + @Column(name = "social_type", nullable = false) + private SocialType socialType; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) private List inquiryList = new ArrayList<>(); diff --git a/src/main/java/umc/domain/member/enums/SocialType.java b/src/main/java/umc/domain/member/enums/SocialType.java new file mode 100644 index 00000000..df1bae80 --- /dev/null +++ b/src/main/java/umc/domain/member/enums/SocialType.java @@ -0,0 +1,8 @@ +package umc.domain.member.enums; + +public enum SocialType { + KAKAO, + NAVER, + GOOGLE, + APPLE +} diff --git a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java index cde8055b..8eb7231e 100644 --- a/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/umc/domain/member/exception/code/MemberErrorCode.java @@ -14,7 +14,13 @@ public enum MemberErrorCode implements BaseErrorCode { "해당 사용자를 찾을 수 없습니다."), MEMBER_ALREADY_EXIST(HttpStatus.ALREADY_REPORTED, "MEMBER404_2", - "이미 존재하는 사용자입니다.") + "이미 존재하는 사용자입니다."), + MEMBER_INVALID_PASSWORD(HttpStatus.NOT_FOUND, + "PASSWORD404_1", + "해당 비밀번호를 찾을 수 없습니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.NOT_FOUND, + "SOCIAL404_1", + "해당 소셜 로그인을 지원하지 않습니다.") ; private final HttpStatus status; private final String code; diff --git a/src/main/java/umc/domain/member/repository/MemberRepository.java b/src/main/java/umc/domain/member/repository/MemberRepository.java index d875125f..9c96a72c 100644 --- a/src/main/java/umc/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/domain/member/repository/MemberRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialType; import java.util.Optional; @@ -9,4 +10,6 @@ public interface MemberRepository extends JpaRepository { Optional findByMail(String mail); boolean existsByMail(String mail); + + Optional findBySocialTypeAndSocialUid(SocialType socialType, String username); } diff --git a/src/main/java/umc/domain/member/service/MemberCommandService.java b/src/main/java/umc/domain/member/service/MemberCommandService.java index 752cf43d..5a68bc67 100644 --- a/src/main/java/umc/domain/member/service/MemberCommandService.java +++ b/src/main/java/umc/domain/member/service/MemberCommandService.java @@ -1,5 +1,6 @@ package umc.domain.member.service; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -16,6 +17,8 @@ import umc.domain.member.repository.MemberRepository; import umc.domain.policy.entity.Policy; import umc.domain.policy.repository.PolicyRepository; +import umc.global.security.entity.AuthMember; +import umc.global.security.util.JwtUtil; import java.util.List; @@ -27,6 +30,7 @@ public class MemberCommandService { private final FoodRepository foodRepository; private final PolicyRepository policyRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; @Transactional public MemberResDTO.JoinResultDTO join(MemberReqDTO.JoinDTO request){ @@ -50,4 +54,19 @@ public MemberResDTO.JoinResultDTO join(MemberReqDTO.JoinDTO request){ //결과 DTO 전송 return MemberConverter.toJoinResultDTO(savedMember); } + + public MemberResDTO.LoginResultDTO login(MemberReqDTO.LoginReqDTO request) { + Member member = memberRepository.findByMail(request.mail()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if(!passwordEncoder.matches(request.password(), member.getPassword())){ + throw new MemberException(MemberErrorCode.MEMBER_INVALID_PASSWORD); + } + + //accseeToken 있어야 함. + AuthMember authMember = new AuthMember(member); + String accessToken = jwtUtil.createAccessToken(authMember); + + return new MemberResDTO.LoginResultDTO(accessToken); + } } diff --git a/src/main/java/umc/domain/member/service/MemberQueryService.java b/src/main/java/umc/domain/member/service/MemberQueryService.java index c856706d..6444ef63 100644 --- a/src/main/java/umc/domain/member/service/MemberQueryService.java +++ b/src/main/java/umc/domain/member/service/MemberQueryService.java @@ -3,10 +3,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import umc.domain.member.converter.MemberConverter; +import umc.domain.member.dto.MemberResDTO; import umc.domain.member.entity.Member; import umc.domain.member.exception.MemberException; import umc.domain.member.exception.code.MemberErrorCode; import umc.domain.member.repository.MemberRepository; +import umc.global.security.entity.AuthMember; @Service @RequiredArgsConstructor @@ -15,8 +18,14 @@ public class MemberQueryService { private final MemberRepository memberRepository; - public Member getMyProfile(Long memberId){ - return memberRepository.findById(memberId) + public MemberResDTO.MyProfileDTO getMyProfile( + AuthMember authMember + ){ + String email = authMember.getUsername(); + + Member member = memberRepository.findByMail(email) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + return MemberConverter.toMyProfileDTO(member); } } diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index 7ab78dc9..b5f40b77 100644 --- a/src/main/java/umc/global/config/SecurityConfig.java +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -1,5 +1,6 @@ package umc.global.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -8,13 +9,24 @@ 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; import umc.global.security.exception.CustomAccessDenied; import umc.global.security.exception.CustomEntryPoint; +import umc.global.security.filter.JwtAuthFilter; +import umc.global.security.handler.OAuthSuccessHandler; +import umc.global.security.service.CustomOAuthService; +import umc.global.security.service.CustomUserDetailsService; +import umc.global.security.util.JwtUtil; @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,21 +34,30 @@ public class SecurityConfig { "/v3/api-docs/**", //로그인만 허용 "/auth/sign-up", + "/auth/login" }; + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + //URI 허용 여부 .authorizeHttpRequests(requests -> requests .requestMatchers(allowUris).permitAll() .anyRequest().authenticated() ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) + //폼 로그인 + .formLogin(AbstractHttpConfigurer::disable) + //세션 + .sessionManagement(AbstractHttpConfigurer::disable) + //JWT필터 + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") @@ -46,10 +67,27 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .accessDeniedHandler(customAccessDenied()) .authenticationEntryPoint(customEntryPoint()) ) + .oauth2Login(oauth -> oauth + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorize") + ) + //콜백 주소 + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuthService) + ) + .successHandler(oAuthSuccessHandler()) + ) ; return http.build(); } + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } @Bean public PasswordEncoder passwordEncoder() { diff --git a/src/main/java/umc/global/security/dto/KakaoDTO.java b/src/main/java/umc/global/security/dto/KakaoDTO.java new file mode 100644 index 00000000..4757e54e --- /dev/null +++ b/src/main/java/umc/global/security/dto/KakaoDTO.java @@ -0,0 +1,31 @@ +package umc.global.security.dto; + +import lombok.RequiredArgsConstructor; +import umc.domain.member.enums.SocialType; + +@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/umc/global/security/dto/OAuthDTO.java b/src/main/java/umc/global/security/dto/OAuthDTO.java new file mode 100644 index 00000000..5032fd3d --- /dev/null +++ b/src/main/java/umc/global/security/dto/OAuthDTO.java @@ -0,0 +1,10 @@ +package umc.global.security.dto; + +import umc.domain.member.enums.SocialType; + +public interface OAuthDTO { + SocialType getSocialType(); + String getSocialUid(); + String getSocialEmail(); + String getName(); +} diff --git a/src/main/java/umc/global/security/entity/AuthMember.java b/src/main/java/umc/global/security/entity/AuthMember.java index bebb6543..e364cc95 100644 --- a/src/main/java/umc/global/security/entity/AuthMember.java +++ b/src/main/java/umc/global/security/entity/AuthMember.java @@ -24,11 +24,11 @@ public Collection getAuthorities() { @Override public @Nullable String getPassword() { - return member.getPassword(); + return null; } @Override public @Nullable String getUsername() { - return member.getMail(); + return member.getSocialUid(); } @Override @@ -50,4 +50,8 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } + + public String getSocialType(){ + return member.getSocialType().name(); + } } diff --git a/src/main/java/umc/global/security/entity/OAuthMember.java b/src/main/java/umc/global/security/entity/OAuthMember.java new file mode 100644 index 00000000..4cf15283 --- /dev/null +++ b/src/main/java/umc/global/security/entity/OAuthMember.java @@ -0,0 +1,36 @@ +package umc.global.security.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import umc.domain.member.entity.Member; + +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/umc/global/security/filter/JwtAuthFilter.java b/src/main/java/umc/global/security/filter/JwtAuthFilter.java new file mode 100644 index 00000000..199dcbcf --- /dev/null +++ b/src/main/java/umc/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,76 @@ +package umc.global.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; +import umc.domain.member.enums.SocialType; +import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseErrorCode; +import umc.global.apiPayload.code.GeneralErrorCode; +import umc.global.security.service.CustomUserDetailsService; +import umc.global.security.util.JwtUtil; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // + String uid = jwtUtil.getUid(token); + SocialType socialType = jwtUtil.getSocialType(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails 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); + } + } +} diff --git a/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java b/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..c3b62958 --- /dev/null +++ b/src/main/java/umc/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,63 @@ +package umc.global.security.handler; + +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.context.annotation.Bean; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import umc.domain.member.converter.MemberConverter; +import umc.domain.member.dto.MemberResDTO; +import umc.domain.member.exception.code.MemberSuccessCode; +import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.entity.AuthMember; +import umc.global.security.entity.OAuthMember; +import umc.global.security.util.JwtUtil; + +import java.io.IOException; + +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + // 사전 작업: Response 매핑할 ObjectMapper 선언 + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = MemberSuccessCode.OK; + + // Content-Type, Status 설정 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getOutputStream(), responseBody); + } + +} diff --git a/src/main/java/umc/global/security/service/CustomOAuthService.java b/src/main/java/umc/global/security/service/CustomOAuthService.java new file mode 100644 index 00000000..d587bf9b --- /dev/null +++ b/src/main/java/umc/global/security/service/CustomOAuthService.java @@ -0,0 +1,85 @@ +package umc.global.security.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +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.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 umc.domain.member.converter.MemberConverter; +import umc.domain.member.dto.MemberResDTO; +import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialType; +import umc.domain.member.exception.MemberException; +import umc.domain.member.exception.code.MemberErrorCode; +import umc.domain.member.exception.code.MemberSuccessCode; +import umc.domain.member.repository.MemberRepository; +import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.BaseSuccessCode; +import umc.global.security.dto.KakaoDTO; +import umc.global.security.dto.OAuthDTO; +import umc.global.security.entity.AuthMember; +import umc.global.security.entity.OAuthMember; +import umc.global.security.util.JwtUtil; + +import java.io.IOException; +import java.util.Map; + +import static umc.domain.member.enums.SocialType.KAKAO; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + // (필수) 인증 서버의 일회성 토큰을 이용해 정보 조회 & 유저 객체 생성 + OAuth2User oAuthMember = super.loadUser(userRequest); + + // 유저 객체에서 정보 추출 + SocialType providerId; + String socialUid; + Map attributes = oAuthMember.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + try { + providerId = SocialType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialUid = String.valueOf((Long) oAuthMember.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // OAuth 공통 정보 DTO로 매핑 + OAuthDTO dto; + switch (providerId) { + case KAKAO -> { + String email = 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/umc/global/security/service/CustomUserDetailsService.java b/src/main/java/umc/global/security/service/CustomUserDetailsService.java index 67c1e39e..024b40ca 100644 --- a/src/main/java/umc/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/umc/global/security/service/CustomUserDetailsService.java @@ -6,6 +6,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialType; import umc.domain.member.exception.MemberException; import umc.domain.member.exception.code.MemberErrorCode; import umc.domain.member.repository.MemberRepository; @@ -17,12 +18,31 @@ public class CustomUserDetailsService implements UserDetailsService { private final MemberRepository memberRepository; - @Override - public UserDetails loadUserByUsername(String mail) + //@Override + /*public UserDetails loadUserByUsername(String mail) throws UsernameNotFoundException { Member member = memberRepository.findByMail(mail) .orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 유저를 찾을 수 없습니다.")); return new AuthMember(member); } + */ + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException{ + Member member = memberRepository.findByMail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return new AuthMember(member); + } + + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, + String username + )throws UsernameNotFoundException { + Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + return new AuthMember(member); + } } diff --git a/src/main/java/umc/global/security/util/JwtUtil.java b/src/main/java/umc/global/security/util/JwtUtil.java new file mode 100644 index 00000000..c3bae719 --- /dev/null +++ b/src/main/java/umc/global/security/util/JwtUtil.java @@ -0,0 +1,111 @@ +package umc.global.security.util; + +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 umc.domain.member.enums.SocialType; +import umc.global.security.entity.AuthMember; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(AuthMember member) { + return createToken(member, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("uid", member.getMember().getSocialUid()) + .claim("social_type", member.getMember().getSocialType().name()) + .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); + } + + public SocialType getSocialType(String token){ + try{ + return SocialType.valueOf(getClaims(token).getPayload().get("social_type").toString().toUpperCase()); + }catch (JwtException e){ + return null; + } + } + + public String getUid(String token){ + try{ + return getClaims(token).getPayload().getSubject(); + }catch(JwtException e){ + return null; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70c391f4..f4da37ef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,29 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "http://localhost:8080/oauth/callback/kakao" + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + userNameAttribute: id + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file