diff --git a/build.gradle b/build.gradle index 4e0fc1c..55a2e29 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,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 879b075..619d13a 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 @@ -1,16 +1,20 @@ package com.example.umc10th.domain.member.controller; - import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; 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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Member", description = "회원 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -18,25 +22,8 @@ public class MemberController { private final MemberService memberService; - @PostMapping("/users/me") - public ApiResponse getInfo( - @RequestBody @Valid MemberReqDTO.GetInfo dto - ) { - BaseSuccessCode code = MemberSuccessCode.OK; - return ApiResponse.success(code, memberService.getInfo(dto)); - } - - // 마이페이지 - // TODO: Security 적용 후 매개변수 변경 - @GetMapping("/myPage") - public ApiResponse getMyPage( - @RequestParam Long memberId - ) { - BaseSuccessCode code = MemberSuccessCode.MY_PAGE_OK; - return ApiResponse.success(code, memberService.getMyPage(memberId)); - } - // 회원가입 + @Operation(summary = "회원가입", description = "이메일/비밀번호 기반 회원가입") @PostMapping("/auth/signup") public ApiResponse signUp( @RequestBody @Valid MemberReqDTO.SignUp dto @@ -44,5 +31,24 @@ public ApiResponse signUp( BaseSuccessCode code = MemberSuccessCode.SIGNUP_OK; return ApiResponse.success(code, memberService.signUp(dto)); } -} + // 로그인 → JWT 발급 + @Operation(summary = "로그인", description = "이메일/비밀번호로 로그인 후 JWT 액세스 토큰 발급") + @PostMapping("/auth/login") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.Login dto + ) { + BaseSuccessCode code = MemberSuccessCode.LOGIN_OK; + return ApiResponse.success(code, memberService.login(dto)); + } + + // 마이페이지 v2 - JWT 토큰에서 사용자 정보 자동 추출 + @Operation(summary = "마이페이지", description = "JWT 토큰으로 인증 후 내 프로필 정보 조회 (Authorization: Bearer {token})") + @GetMapping("/users/me") + public ApiResponse getMyPage( + @AuthenticationPrincipal AuthMember member + ) { + BaseSuccessCode code = MemberSuccessCode.MY_PAGE_OK; + return ApiResponse.success(code, memberService.getInfo(member)); + } +} 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 a654dbc..eacaae2 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 @@ -3,6 +3,10 @@ import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.Address; +import com.example.umc10th.global.security.dto.OAuthDTO; + +import java.time.LocalDate; public class MemberConverter { @@ -20,7 +24,7 @@ public static MemberResDTO.GetInfo toGetInfo(Member member) { .build(); } - // 회원가입 + // 일반 회원가입 (이메일/비밀번호) public static Member toMember(MemberReqDTO.SignUp dto, String encodedPassword) { return Member.builder() .name(dto.name()) @@ -34,6 +38,21 @@ public static Member toMember(MemberReqDTO.SignUp dto, String encodedPassword) { .build(); } + // OAuth 회원가입 (소셜 로그인 신규 유저) + // - 비밀번호/생년월일/주소 등 OAuth에서 제공하지 않는 정보는 기본값 처리 + public static Member toMember(OAuthDTO dto) { + return Member.builder() + .name(dto.getName()) + .email(dto.getSocialEmail()) + .socialType(dto.getSocialType()) + .socialUid(dto.getSocialUid()) + .password("") // OAuth 유저는 비밀번호 없음 + .address(Address.NONE) // 추후 프로필 설정에서 입력 + .detailAddress("") // 추후 프로필 설정에서 입력 + .birth(LocalDate.of(1900, 1, 1)) // 추후 프로필 설정에서 입력 + .build(); + } + public static MemberResDTO.SignUp toSignUp(Member member) { return MemberResDTO.SignUp.builder() .memberId(member.getId()) @@ -42,4 +61,12 @@ public static MemberResDTO.SignUp toSignUp(Member member) { .createdAt(member.getCratedAt()) .build(); } + + // OAuth 로그인 성공 응답 DTO + public static MemberResDTO.Login toLogin(String accessToken) { + return MemberResDTO.Login.builder() + .accessToken(accessToken) + .build(); + } } + 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 b2a4725..373f7fc 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 @@ -2,6 +2,8 @@ import com.example.umc10th.domain.member.enums.Address; import com.example.umc10th.domain.member.enums.Gender; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @@ -15,7 +17,6 @@ public record GetInfo( // 회원가입 public record SignUp( - @NotNull(message = "이름을 입력해주세요.") String name, @NotNull(message = "성별을 입력해주세요.") @@ -25,6 +26,7 @@ public record SignUp( @NotNull(message = "주소를 입력해주세요.") Address address, String detailAddress, + @Email(message = "올바른 이메일 형식을 입력해주세요.") @NotNull(message = "이메일을 입력해주세요.") String email, @NotNull(message = "비밀번호를 입력해주세요.") @@ -32,4 +34,13 @@ public record SignUp( @NotNull(message = "전화번호를 입력해주세요.") String phoneNumber ) {} + + // 로그인 + public record Login( + @Email(message = "올바른 이메일 형식을 입력해주세요.") + @NotBlank(message = "이메일을 입력해주세요.") + String email, + @NotBlank(message = "비밀번호를 입력해주세요.") + String password + ) {} } 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 ecf47b0..642fffe 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 @@ -27,5 +27,9 @@ public record SignUp( String email, LocalDateTime createdAt ) {} -} + @Builder + public record Login( + String accessToken + ) {} +} diff --git a/src/main/java/com/example/umc10th/domain/member/enums/Address.java b/src/main/java/com/example/umc10th/domain/member/enums/Address.java index 6a9fe2e..6e85780 100644 --- a/src/main/java/com/example/umc10th/domain/member/enums/Address.java +++ b/src/main/java/com/example/umc10th/domain/member/enums/Address.java @@ -1,6 +1,7 @@ package com.example.umc10th.domain.member.enums; public enum Address { + NONE, // 미설정 (OAuth 회원가입 초기값) SEOUL, GYEONGGI, INCHEON, 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 66ecdee..695fc99 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 @@ -12,6 +12,14 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER404_1", "사용자가 존재하지 않습니다."), + + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, + "MEMBER401_1", + "비밀번호가 일치하지 않습니다."), + + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, + "MEMBER400_1", + "지원하지 않는 소셜 로그인 제공자입니다."), ; private final HttpStatus status; 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 5a96f77..5fa2d03 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 @@ -18,6 +18,10 @@ public enum MemberSuccessCode implements BaseSuccessCode { "MEMBER200_2", "마이페이지 정보를 성공적으로 조회했습니다."), + LOGIN_OK(HttpStatus.OK, + "MEMBER200_3", + "로그인이 성공적으로 완료되었습니다."), + SIGNUP_OK(HttpStatus.CREATED, "MEMBER201_1", "회원가입이 성공적으로 완료되었습니다."), 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 64887e5..a746bc9 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,12 +1,15 @@ 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; -public interface MemberRepository extends JpaRepository { +public interface MemberRepository extends JpaRepository { Optional findByNameAndDeletedAtIsNull(String name); - Optional findByEmail(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 2916b39..45cf08b 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 @@ -4,8 +4,12 @@ import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; 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.apiPayload.code.GeneralErrorCode; +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; @@ -18,26 +22,44 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + // 구버전: Request Body로 ID를 직접 받아 조회 public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) { // TODO: 구현 예정 return null; } - public MemberResDTO.GetInfo getMyPage(Long memberId) { + // v2: SecurityContextHolder에서 AuthMember를 받아 컨버터에 직접 전달 (추가 DB 조회 불필요) + public MemberResDTO.GetInfo getInfo(AuthMember authMember) { + return MemberConverter.toGetInfo(authMember.getMember()); + } - Member member = memberRepository.findById(Math.toIntExact(memberId)) + public MemberResDTO.GetInfo getMyPage(Long memberId) { + Member member = memberRepository.findById(memberId) .orElseThrow(() -> new RuntimeException(GeneralErrorCode.NOT_FOUND.getMessage())); - return MemberConverter.toGetInfo(member); } + // 회원가입 @Transactional public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp dto) { String encodedPassword = passwordEncoder.encode(dto.password()); Member member = MemberConverter.toMember(dto, encodedPassword); Member savedMember = memberRepository.save(member); - return MemberConverter.toSignUp(savedMember); } + + public MemberResDTO.Login login(MemberReqDTO.Login dto) { + + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (!passwordEncoder.matches(dto.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + + String accessToken = jwtUtil.createAccessToken(new AuthMember(member)); + return MemberConverter.toLogin(accessToken); + } } diff --git a/src/main/java/com/example/umc10th/domain/mission/repository/MissionRespository.java b/src/main/java/com/example/umc10th/domain/mission/repository/MissionRespository.java index c3bf86a..0eb1929 100644 --- a/src/main/java/com/example/umc10th/domain/mission/repository/MissionRespository.java +++ b/src/main/java/com/example/umc10th/domain/mission/repository/MissionRespository.java @@ -13,7 +13,7 @@ public interface MissionRespository extends JpaRepository { @EntityGraph(attributePaths = "store") - Page findByStore_Location_Id(Integer locationId, Pageable pageable); + Page findByStore_Location_Id(Long locationId, Pageable pageable); List findAllByStore_Id(Long storeId); diff --git a/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java b/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java index 0e49e70..206d31e 100644 --- a/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java +++ b/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java @@ -37,11 +37,11 @@ public class MissionService { // 홈 화면 public MissionResDTO.GetHome getHome(MissionReqDTO.GetHome dto, Pageable pageable) { - Member member = memberRepository.findById(Math.toIntExact(dto.memberId())) + Member member = memberRepository.findById(dto.memberId()) .orElseThrow(() -> new RuntimeException(GeneralErrorCode.NOT_FOUND.getMessage())); Page missionPage = missionRepository.findByStore_Location_Id( - Math.toIntExact(dto.locationId()), pageable + dto.locationId(), pageable ); return MissionConverter.toGetHome(member, missionPage); diff --git a/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index c117e70..5c2f7cd 100644 --- a/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -33,7 +33,7 @@ public class ReviewService { // 리뷰 작성 public ReviewResDTO.CreateReview createReview(Long storeId, ReviewReqDTO.CreateReview dto) { - Member member = memberRepository.findById(Math.toIntExact(dto.memberId())) + Member member = memberRepository.findById(dto.memberId()) .orElseThrow(() -> new RuntimeException(GeneralErrorCode.NOT_FOUND.getMessage())); Store store = storeRepository.findById(storeId) diff --git a/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java b/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java index 793689c..967fd4a 100644 --- a/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java +++ b/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; @Getter @JsonPropertyOrder({"isSuccess","timestamp","code","message","result"}) @@ -25,14 +26,15 @@ public class ApiResponse { private final String message; @JsonProperty("timestamp") - private final LocalDateTime timestamp; + private final String timestamp; @JsonProperty("result") private T result; private ApiResponse(Boolean isSuccess, String code, String message, T result) { this.isSuccess = isSuccess; - this.timestamp = LocalDateTime.now(); + this.timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); this.code = code; this.message = message; this.result = result; @@ -43,6 +45,11 @@ public static ApiResponse success(BaseSuccessCode code, T result) { return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); } + // [성공] 필터/핸들러에서 ObjectMapper로 직접 쓸 때 사용 (success의 alias) + public static ApiResponse onSuccess(BaseSuccessCode code, T result) { + return success(code, result); + } + // [실패] 에러 핸들러에서 사용 (데이터 없는 일반 에러) public static ResponseEntity> onFailureEntity(BaseErrorCode code) { return ResponseEntity @@ -57,6 +64,11 @@ public static ResponseEntity> onFailureEntity(BaseErrorCode c .body(onFailureBody(code, result)); } + // [실패] 필터에서 ObjectMapper로 직접 쓸 때 사용 (ResponseEntity 없이 body만 필요) + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return onFailureBody(code, result); + } + private static ApiResponse onFailureBody(BaseErrorCode code, T result) { return new ApiResponse<>(false, code.getCode(), code.getMessage(), result); } 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 f4198e0..202e2b1 100644 --- a/src/main/java/com/example/umc10th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -2,6 +2,11 @@ import com.example.umc10th.global.security.exception.CustomAccessDenied; import com.example.umc10th.global.security.exception.CustomEntryPoint; +import com.example.umc10th.global.security.filter.JwtAuthFilter; +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.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; @@ -9,9 +14,11 @@ 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; import org.springframework.web.servlet.HandlerExceptionResolver; @EnableWebSecurity @@ -22,32 +29,68 @@ public class SecurityConfig { @Qualifier("handlerExceptionResolver") private final HandlerExceptionResolver handlerExceptionResolver; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final CustomOAuthService customOAuthService; + + // 인증 없이 허용할 URI 목록 private final String[] allowUris = { - // Swagger 허용 + // Swagger "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - "/api/v1/auth/signup" + // 인증 관련 Public API + "/api/v1/auth/signup", + "/api/v1/auth/login", + // OAuth2 인증 엔드포인트 & 콜백 + "/oauth/authorize/**", + "/oauth/callback/**", + // Spring 에러 처리 (이게 없으면 에러 응답 committed 후 AccessDenied 발생) + "/error" }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + // CSRF 비활성화 (JWT 사용 시 불필요) .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(session -> + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) - .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") - .permitAll() + + // JWT 필터: UsernamePasswordAuthenticationFilter 앞에 등록 + .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()) ) - .exceptionHandling(exception->exception + + // 예외 상황 핸들러 + .exceptionHandling(exception -> exception .accessDeniedHandler(customAccessDenied()) .authenticationEntryPoint(customEntryPoint()) ) @@ -56,6 +99,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -71,4 +124,3 @@ public CustomEntryPoint customEntryPoint() { return new CustomEntryPoint(handlerExceptionResolver); } } - 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..01efa2c --- /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; // 카카오 고유 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 ec2844d..2dfe386 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 @@ -3,7 +3,6 @@ import com.example.umc10th.domain.member.entity.Member; import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.jspecify.annotations.Nullable; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -16,19 +15,18 @@ public class AuthMember implements UserDetails { private final Member member; - @Override public Collection getAuthorities() { return List.of(); } @Override - public @Nullable String getPassword() { - return member.getPassword(); + public String getPassword() { + return null; } @Override public String getUsername() { - return member.getEmail(); + return member.getId().toString(); } } 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..d69daa9 --- /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; + +@Getter +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + 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..53771ff --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,80 @@ +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.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 subject에서 memberId 추출 + Long memberId = Long.parseLong(jwtUtil.getUid(token)); + + // memberId로 회원 조회 → 인증 객체 생성 (일반/OAuth 공통) + UserDetails member = customUserDetailsService.loadUserById(memberId); + Authentication auth = new UsernamePasswordAuthenticationToken( + member, + null, + member.getAuthorities() + ); + + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + + } catch (Exception e) { + ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + 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/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..3f69f0d --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,58 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberResDTO; +import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.entity.OAuthMember; +import com.example.umc10th.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + + // 사전 작업: Response 매핑할 ObjectMapper 선언 + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = MemberSuccessCode.OK; + + // Content-Type, Status 설정 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 인증 객체 컨테이너에서 OAuth 인증 객체 가져오기 + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + // 토큰 제작을 위해 OAuth 인증 객체에서 Member 추출 -> AuthMember 제작 + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 응답 통일 객체 래핑 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + MemberConverter.toLogin(accessToken) + ); + + // 응답 출력 + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} 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..2daefec --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java @@ -0,0 +1,74 @@ +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 org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + @Transactional + 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 { + // ex) "kakao" → SocialType.KAKAO + 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로 매핑 (제공자별 응답 구조가 다르므로 switch로 분기) + 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/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java index 4176178..582f6bc 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,25 +1,45 @@ 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(String username) throws UsernameNotFoundException { - Member member = memberRepository.findByEmail(username) + /** + * memberId로 회원 조회 후 인증 객체 반환 + * JwtAuthFilter에서 토큰 검증 후 호출됨 (일반/OAuth 공통) + * + * @param memberId JWT subject에 담긴 회원 PK + */ + public UserDetails loadUserById(Long memberId) throws UsernameNotFoundException { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return new AuthMember(member); + } + + /** + * 소셜 로그인 타입 + UID로 회원 조회 (CustomOAuthService에서 사용) + * + * @param socialType 소셜 로그인 타입 (KAKAO 등) + * @param uid 소셜 로그인 고유 ID + */ + public UserDetails loadUserByUidAndSocialType( + SocialType socialType, + String uid + ) throws UsernameNotFoundException { + Member member = memberRepository.findBySocialTypeAndSocialUid(socialType, uid) .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..a3bbc02 --- /dev/null +++ b/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,112 @@ +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); + } + + /** + * 토큰에서 소셜 UID 가져오기 (Subject) + * + * @param token 유저 정보를 추출할 토큰 + * @return 소셜 로그인 UID + */ + public String getUid(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + /** + * 토큰에서 SocialType 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 소셜 로그인 타입 (KAKAO, NAVER 등) + */ + public SocialType getSocialType(String token) { + try { + return SocialType.valueOf( + getClaims(token).getPayload().get("social_type").toString().toUpperCase() + ); + } catch (JwtException | IllegalArgumentException 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) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f7681ea..ccef32e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,19 +1,44 @@ spring: application: - name: "umc10th" # "umc10th" + name: "umc10th" datasource: - driver-class-name: com.mysql.cj.jdbc.Driver # MySQL JDBC ???? ??? ?? - url: ${DB_URL} # jdbc:mysql://localhost:3306/{???????} - username: ${DB_USER} # MySQL ?? ?? - password: ${DB_PW} # MySQL ???? + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PW} jpa: - database: mysql # ??? ?????? ?? ?? (MySQL) - database-platform: org.hibernate.dialect.MySQLDialect # Hibernate?? ??? MySQL ??(dialect) ?? - show-sql: true # ??? SQL ??? ??? ???? ?? ?? + database: mysql + database-platform: org.hibernate.dialect.MySQLDialect + show-sql: true hibernate: - ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? + ddl-auto: update properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "http://localhost:8080/oauth/callback/kakao" + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + user-name-attribute: id # userNameAttribute → YAML 표준 케밥케이스 + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 \ No newline at end of file