diff --git a/build.gradle b/build.gradle index 49fcfd14..52c4bbf4 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,15 @@ dependencies { // Spring 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/umc/domain/auth/controller/AuthController.java b/src/main/java/umc/domain/auth/controller/AuthController.java index 8fafbcc2..93ecfb2d 100644 --- a/src/main/java/umc/domain/auth/controller/AuthController.java +++ b/src/main/java/umc/domain/auth/controller/AuthController.java @@ -8,10 +8,8 @@ import org.springframework.web.bind.annotation.RestController; import umc.domain.auth.dto.AuthReqDTO; import umc.domain.auth.dto.AuthResDTO; +import umc.domain.auth.exception.code.AuthSuccessCode; import umc.domain.auth.service.AuthService; -import umc.domain.member.dto.MemberReqDTO; -import umc.domain.member.dto.MemberResDTO; -import umc.domain.member.exception.code.MemberSuccessCode; import umc.global.apiPayload.ApiResponse; @RestController @@ -26,6 +24,14 @@ public ApiResponse signUp( @RequestBody @Valid AuthReqDTO.SignUpDTO reqDto ){ AuthResDTO.SignUpDTO resDto = authService.signUp(reqDto); - return ApiResponse.onSuccess(MemberSuccessCode.CREATED, resDto); + return ApiResponse.onSuccess(AuthSuccessCode.SIGN_UP, resDto); + } + + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid AuthReqDTO.LoginDTO reqDto + ) { + AuthResDTO.LoginDTO resDto = authService.login(reqDto); + return ApiResponse.onSuccess(AuthSuccessCode.LOGIN_SUCCESS, resDto); } } diff --git a/src/main/java/umc/domain/auth/dto/AuthReqDTO.java b/src/main/java/umc/domain/auth/dto/AuthReqDTO.java index 29d9a81a..37188a52 100644 --- a/src/main/java/umc/domain/auth/dto/AuthReqDTO.java +++ b/src/main/java/umc/domain/auth/dto/AuthReqDTO.java @@ -1,10 +1,7 @@ package umc.domain.auth.dto; import jakarta.validation.Valid; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.*; import umc.domain.member.enums.Gender; import java.util.List; @@ -27,6 +24,7 @@ public record SignUpDTO( @NotBlank String address, @Valid + @NotEmpty List terms, @Valid List foodPreferences @@ -43,4 +41,12 @@ public record FoodPreferenceDTO( Long foodId ) {} } + + public record LoginDTO( + @NotBlank + @Email + String email, + @NotBlank + String password + ) {} } diff --git a/src/main/java/umc/domain/auth/dto/AuthResDTO.java b/src/main/java/umc/domain/auth/dto/AuthResDTO.java index b7dc4015..e523461e 100644 --- a/src/main/java/umc/domain/auth/dto/AuthResDTO.java +++ b/src/main/java/umc/domain/auth/dto/AuthResDTO.java @@ -14,4 +14,9 @@ public record SignUpDTO( LocalDate birth, String address ) {} + + @Builder + public record LoginDTO( + String accessToken + ) {} } diff --git a/src/main/java/umc/domain/auth/exception/code/AuthErrorCode.java b/src/main/java/umc/domain/auth/exception/code/AuthErrorCode.java index b29baa66..ee3342bf 100644 --- a/src/main/java/umc/domain/auth/exception/code/AuthErrorCode.java +++ b/src/main/java/umc/domain/auth/exception/code/AuthErrorCode.java @@ -9,11 +9,18 @@ @AllArgsConstructor public enum AuthErrorCode implements BaseErrorCode { - DUPLICATED_EMAIL(HttpStatus.NOT_FOUND, "AUTH400_1", "이미 가입되어있는 이메일입니다."), - REQUIRED_TERM_NOT_AGREED(HttpStatus.BAD_REQUEST, "AUTH404_1", "필수 약관은 동의해야 합니다."), - INVALID_TERM(HttpStatus.BAD_REQUEST, "AUTH404_2", "유효하지 않은 약관입니다."), - INVALID_FOOD(HttpStatus.BAD_REQUEST, "AUTH404_3", "유효하지 않은 음식입니다."), - TERMS_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH404_4", "모든 약관에 대한 동의 여부가 필요합니다."), + // 400 + REQUIRED_TERM_NOT_AGREED(HttpStatus.BAD_REQUEST, "AUTH400_1", "필수 약관은 동의해야 합니다."), + INVALID_TERM(HttpStatus.BAD_REQUEST, "AUTH400_2", "유효하지 않은 약관입니다."), + INVALID_FOOD(HttpStatus.BAD_REQUEST, "AUTH400_3", "유효하지 않은 음식입니다."), + TERMS_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH400_4", "모든 약관에 대한 동의 여부가 필요합니다."), + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH400_5", "지원하지 않는 소셜 로그인입니다."), + + // 401 + INVALID_LOGIN_FORM(HttpStatus.UNAUTHORIZED, "AUTH401_2", "아이디나 비밀번호가 틀렸습니다."), + + // 409 + DUPLICATED_EMAIL(HttpStatus.CONFLICT, "AUTH409_1", "이미 가입되어있는 이메일입니다."), ; private final HttpStatus status; diff --git a/src/main/java/umc/domain/auth/exception/code/AuthSuccessCode.java b/src/main/java/umc/domain/auth/exception/code/AuthSuccessCode.java index 75af0cbd..a5c5e798 100644 --- a/src/main/java/umc/domain/auth/exception/code/AuthSuccessCode.java +++ b/src/main/java/umc/domain/auth/exception/code/AuthSuccessCode.java @@ -1,4 +1,22 @@ package umc.domain.auth.exception.code; -public enum AuthSuccessCode { +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import umc.global.apiPayload.code.BaseSuccessCode; + +@Getter +@AllArgsConstructor +public enum AuthSuccessCode implements BaseSuccessCode { + + // 200 + LOGIN_SUCCESS(HttpStatus.OK, "AUTH200_1", "로그인이 성공적으로 완료되었습니다."), + + // 201 + SIGN_UP(HttpStatus.CREATED, "AUTH201_1", "회원가입이 완료되었습니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/src/main/java/umc/domain/auth/service/AuthService.java b/src/main/java/umc/domain/auth/service/AuthService.java index a6b75926..16eb80b6 100644 --- a/src/main/java/umc/domain/auth/service/AuthService.java +++ b/src/main/java/umc/domain/auth/service/AuthService.java @@ -1,6 +1,10 @@ package umc.domain.auth.service; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +19,8 @@ import umc.domain.member.repository.FoodRepository; import umc.domain.member.repository.MemberRepository; import umc.domain.member.repository.TermRepository; +import umc.global.security.entity.AuthMember; +import umc.global.security.util.JwtUtil; import java.util.List; import java.util.Map; @@ -30,6 +36,8 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final TermRepository termRepository; private final FoodRepository foodRepository; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; @Transactional public AuthResDTO.SignUpDTO signUp(AuthReqDTO.SignUpDTO reqDto) { @@ -47,6 +55,22 @@ public AuthResDTO.SignUpDTO signUp(AuthReqDTO.SignUpDTO reqDto) { return AuthConverter.toSignUpDTO(member); } + public AuthResDTO.LoginDTO login(AuthReqDTO.LoginDTO reqDto) { + Authentication authentication; + + try { + authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(reqDto.email(), reqDto.password()) + ); + } catch (AuthenticationException e) { + throw new AuthException(AuthErrorCode.INVALID_LOGIN_FORM); + } + + String accessToken = jwtUtil.createAccessToken((AuthMember) authentication.getPrincipal()); + + return new AuthResDTO.LoginDTO(accessToken); + } + private void addTermsToMember(Member member, List termDTOs) { List allTerms = termRepository.findAll(); @@ -58,7 +82,7 @@ private void addTermsToMember(Member member, List .map(AuthReqDTO.SignUpDTO.TermDTO::termId) .collect(Collectors.toSet()); - if (!allTermIds.containsAll(requestedTermIds)) { + if (termDTOs.size() != requestedTermIds.size() || !allTermIds.equals(requestedTermIds)) { throw new AuthException(AuthErrorCode.TERMS_MISMATCH); } @@ -75,10 +99,15 @@ private void addTermsToMember(Member member, List } private void addFoodsToMember(Member member, List foodDTOs) { - foodDTOs.forEach(foodDTO -> { - Food food = foodRepository.findById(foodDTO.foodId()) - .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_FOOD)); - member.addPreferenceFood(food); - }); + List foodIds = foodDTOs.stream() + .map(AuthReqDTO.SignUpDTO.FoodPreferenceDTO::foodId) + .toList(); + + List foods = foodRepository.findAllById(foodIds); + if (foodIds.size() != foods.size()) { + throw new AuthException(AuthErrorCode.INVALID_FOOD); + } + + foods.forEach(member::addPreferenceFood); } } diff --git a/src/main/java/umc/domain/member/controller/MemberController.java b/src/main/java/umc/domain/member/controller/MemberController.java index 929d9f25..352f3480 100644 --- a/src/main/java/umc/domain/member/controller/MemberController.java +++ b/src/main/java/umc/domain/member/controller/MemberController.java @@ -1,16 +1,19 @@ package umc.domain.member.controller; -import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import umc.domain.member.dto.MemberReqDTO; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import umc.domain.member.dto.MemberResDTO; import umc.domain.member.exception.code.MemberSuccessCode; import umc.domain.member.service.MemberService; import umc.global.apiPayload.ApiResponse; +import umc.global.security.entity.AuthMember; import java.time.LocalDate; @@ -40,8 +43,10 @@ public ApiResponse getHome( } @GetMapping("/me") - public ApiResponse getMyPage(){ - MemberResDTO.MyPageViewDTO resDto = memberService.getMyPage(1L); + public ApiResponse getMyPage( + @AuthenticationPrincipal AuthMember authMember + ){ + MemberResDTO.MyPageViewDTO resDto = memberService.getMyPage(authMember.getMember().getId()); return ApiResponse.onSuccess(MemberSuccessCode.MY_PAGE_VIEW, resDto); } } diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index aaf2f9ff..c1ba3c19 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -2,8 +2,10 @@ import umc.domain.member.dto.MemberResDTO; import umc.domain.member.entity.Member; +import umc.domain.member.enums.Gender; import umc.domain.mission.entity.Mission; import umc.domain.region.entity.Region; +import umc.global.security.dto.OAuthDTO; import java.time.LocalDate; import java.time.temporal.ChronoUnit; @@ -61,4 +63,14 @@ public static MemberResDTO.HomeViewDTO toHomeViewDTO( .hasNext(hasNextPage) .build(); } + + public static Member toMember(OAuthDTO oAuthDTO) { + return Member.builder() + .name(oAuthDTO.getName()) + .email(oAuthDTO.getEmail()) + .socialUid(oAuthDTO.getSocialUid()) + .socialType(oAuthDTO.getSocialType()) + .gender(Gender.NONE) + .build(); + } } diff --git a/src/main/java/umc/domain/member/entity/Member.java b/src/main/java/umc/domain/member/entity/Member.java index 20a5c3c0..1edf58ab 100644 --- a/src/main/java/umc/domain/member/entity/Member.java +++ b/src/main/java/umc/domain/member/entity/Member.java @@ -1,13 +1,14 @@ package umc.domain.member.entity; import jakarta.persistence.*; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; import umc.domain.member.entity.mapping.MemberFood; import umc.domain.member.entity.mapping.MemberTerm; import umc.domain.member.enums.Gender; import umc.domain.member.enums.SocialType; -import umc.domain.mission.entity.mapping.MemberMission; -import umc.domain.review.entity.Review; import umc.global.entity.BaseEntity; import java.time.LocalDate; @@ -61,6 +62,9 @@ public class Member extends BaseEntity { @Column(name = "social_type") private SocialType socialType; + @Column(name = "social_uid") + private String socialUid; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List memberTerms = new ArrayList<>(); diff --git a/src/main/java/umc/domain/member/repository/MemberRepository.java b/src/main/java/umc/domain/member/repository/MemberRepository.java index 831731e7..f1a5dd4e 100644 --- a/src/main/java/umc/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/domain/member/repository/MemberRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialType; import java.util.Optional; @@ -10,4 +11,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); + + Optional findBySocialTypeAndSocialUid(SocialType providerId, String socialUid); } diff --git a/src/main/java/umc/global/apiPayload/code/GeneralSuccessCode.java b/src/main/java/umc/global/apiPayload/code/GeneralSuccessCode.java index cad72ba6..a3d65d68 100755 --- a/src/main/java/umc/global/apiPayload/code/GeneralSuccessCode.java +++ b/src/main/java/umc/global/apiPayload/code/GeneralSuccessCode.java @@ -11,9 +11,9 @@ public enum GeneralSuccessCode implements BaseSuccessCode { OK(HttpStatus.OK, "SUCCESS200_1", "요청이 성공적으로 처리되었습니다."), CREATED(HttpStatus.CREATED, "SUCCESS201_1", "리소스가 성공적으로 생성되었습니다."), ACCEPTED(HttpStatus.ACCEPTED, "SUCCESS202_1", "요청이 수락되었으며, 처리가 진행 중입니다."), - NO_CONTENT(HttpStatus.NO_CONTENT, "SUCCESS203_1", "요청은 성공했으나, 반환할 콘텐츠가 없습니다."); + NO_CONTENT(HttpStatus.NO_CONTENT, "SUCCESS204_1", "요청은 성공했으나, 반환할 콘텐츠가 없습니다."); private final HttpStatus status; private final String code; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/umc/global/config/AppConfig.java b/src/main/java/umc/global/config/AppConfig.java new file mode 100644 index 00000000..88c60fed --- /dev/null +++ b/src/main/java/umc/global/config/AppConfig.java @@ -0,0 +1,14 @@ +package umc.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index ede0becd..a08664f6 100644 --- a/src/main/java/umc/global/config/SecurityConfig.java +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -1,48 +1,69 @@ package umc.global.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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 umc.global.security.CustomAccessDenied; -import umc.global.security.CustomEntryPoint; +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.filter.OAuthSuccessHandler; +import umc.global.security.service.CustomOAuth2UserService; @EnableWebSecurity @Configuration +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthFilter jwtAuthFilter; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final CustomEntryPoint customEntryPoint; + private final CustomAccessDenied customAccessDenied; + private final String[] allowUris = { - // Swagger 허용 "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", - "/auth/**" + "/auth/**", + "/oauth2/**", + "/login/oauth2/**" }; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2UserService customOAuth2UserService) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .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.STATELESS)) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuthSuccessHandler) ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() ) .exceptionHandling(exception -> exception - .accessDeniedHandler(customAccessDenied()) - .authenticationEntryPoint(customEntryPoint()) + .accessDeniedHandler(customAccessDenied) + .authenticationEntryPoint(customEntryPoint) ); return http.build(); @@ -54,12 +75,9 @@ public PasswordEncoder passwordEncoder() { } @Bean - public CustomAccessDenied customAccessDenied() { - return new CustomAccessDenied(); - } - - @Bean - public CustomEntryPoint customEntryPoint() { - return new CustomEntryPoint(); + public AuthenticationManager authenticationManager( + AuthenticationConfiguration configuration + ) throws Exception { + return configuration.getAuthenticationManager(); } } 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..ed5c4efe --- /dev/null +++ b/src/main/java/umc/global/security/dto/KakaoDTO.java @@ -0,0 +1,32 @@ +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 getEmail() { + 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..274cba7a --- /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 getEmail(); + String getName(); +} diff --git a/src/main/java/umc/global/security/AuthMember.java b/src/main/java/umc/global/security/entity/AuthMember.java similarity index 95% rename from src/main/java/umc/global/security/AuthMember.java rename to src/main/java/umc/global/security/entity/AuthMember.java index 4a195743..3971ba2d 100644 --- a/src/main/java/umc/global/security/AuthMember.java +++ b/src/main/java/umc/global/security/entity/AuthMember.java @@ -1,4 +1,4 @@ -package umc.global.security; +package umc.global.security.entity; import jakarta.annotation.Nullable; import lombok.Getter; 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..46a8a845 --- /dev/null +++ b/src/main/java/umc/global/security/entity/OAuthMember.java @@ -0,0 +1,35 @@ +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/CustomAccessDenied.java b/src/main/java/umc/global/security/exception/CustomAccessDenied.java similarity index 81% rename from src/main/java/umc/global/security/CustomAccessDenied.java rename to src/main/java/umc/global/security/exception/CustomAccessDenied.java index 7fdd53e2..f179e360 100644 --- a/src/main/java/umc/global/security/CustomAccessDenied.java +++ b/src/main/java/umc/global/security/exception/CustomAccessDenied.java @@ -1,36 +1,38 @@ -package umc.global.security; +package umc.global.security.exception; 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.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; import umc.global.apiPayload.ApiResponse; import umc.global.apiPayload.code.BaseErrorCode; import umc.global.apiPayload.code.GeneralErrorCode; import java.io.IOException; +@Component +@RequiredArgsConstructor public class CustomAccessDenied implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + @Override public void handle( HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException ) throws IOException, ServletException { - ObjectMapper objectMapper = new ObjectMapper(); BaseErrorCode code = GeneralErrorCode.FORBIDDEN; - // 응답 Content-Type, HTTP 상태코드 정의 response.setContentType("application/json;charset=UTF-8"); response.setStatus(code.getStatus().value()); - // Response Body에 응답통일한 객체를 넣기 - ApiResponse errorResponse = ApiResponse.onFailure(code,null); + ApiResponse errorResponse = ApiResponse.onFailure(code, null); - // 실제 Response로 덮어쓰기 objectMapper.writeValue(response.getOutputStream(), errorResponse); } } diff --git a/src/main/java/umc/global/security/CustomEntryPoint.java b/src/main/java/umc/global/security/exception/CustomEntryPoint.java similarity index 81% rename from src/main/java/umc/global/security/CustomEntryPoint.java rename to src/main/java/umc/global/security/exception/CustomEntryPoint.java index d24bb2fb..270e4072 100644 --- a/src/main/java/umc/global/security/CustomEntryPoint.java +++ b/src/main/java/umc/global/security/exception/CustomEntryPoint.java @@ -1,36 +1,38 @@ -package umc.global.security; +package umc.global.security.exception; 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.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; import umc.global.apiPayload.ApiResponse; import umc.global.apiPayload.code.BaseErrorCode; import umc.global.apiPayload.code.GeneralErrorCode; import java.io.IOException; +@Component +@RequiredArgsConstructor public class CustomEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException ) throws IOException, ServletException { - ObjectMapper objectMapper = new ObjectMapper(); BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; - // 응답 Content-Type, HTTP 상태코드 정의 response.setContentType("application/json;charset=UTF-8"); response.setStatus(code.getStatus().value()); - // Response Body에 응답통일한 객체를 넣기 - ApiResponse errorResponse = ApiResponse.onFailure(code,null); + ApiResponse errorResponse = ApiResponse.onFailure(code, null); - // 실제 Response로 덮어쓰기 objectMapper.writeValue(response.getOutputStream(), errorResponse); } } 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..bb3ccac0 --- /dev/null +++ b/src/main/java/umc/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,72 @@ +package umc.global.security.filter; + +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.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +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; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + String token = request.getHeader("Authorization"); + + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + token = token.replace("Bearer ", ""); + if (jwtUtil.isValid(token)) { + String email = jwtUtil.getEmail(token); + + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} diff --git a/src/main/java/umc/global/security/filter/OAuthSuccessHandler.java b/src/main/java/umc/global/security/filter/OAuthSuccessHandler.java new file mode 100644 index 00000000..f751b0b4 --- /dev/null +++ b/src/main/java/umc/global/security/filter/OAuthSuccessHandler.java @@ -0,0 +1,51 @@ +package umc.global.security.filter; + +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 umc.domain.auth.dto.AuthResDTO; +import umc.domain.auth.exception.code.AuthSuccessCode; +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 +@Component +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + BaseSuccessCode code = AuthSuccessCode.LOGIN_SUCCESS; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + OAuthMember member = (OAuthMember) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + ApiResponse responseBody = ApiResponse.onSuccess( + code, + new AuthResDTO.LoginDTO(accessToken) + ); + + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} diff --git a/src/main/java/umc/global/security/service/CustomOAuth2UserService.java b/src/main/java/umc/global/security/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..fdbb7926 --- /dev/null +++ b/src/main/java/umc/global/security/service/CustomOAuth2UserService.java @@ -0,0 +1,62 @@ +package umc.global.security.service; + +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 umc.domain.auth.exception.AuthException; +import umc.domain.auth.exception.code.AuthErrorCode; +import umc.domain.member.converter.MemberConverter; +import umc.domain.member.entity.Member; +import umc.domain.member.enums.SocialType; +import umc.domain.member.repository.MemberRepository; +import umc.global.security.dto.KakaoDTO; +import umc.global.security.dto.OAuthDTO; +import umc.global.security.entity.OAuthMember; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + SocialType providerId; + String socialUid; + Map attributes = oAuth2User.getAttribute("kakao_account"); + Map profile = (Map) attributes.get("profile"); + try { + providerId = SocialType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialUid = String.valueOf((Long) oAuth2User.getAttribute("id")); + } catch (IllegalArgumentException e) { + throw new AuthException(AuthErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + 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 AuthException(AuthErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + Member member = memberRepository.findBySocialTypeAndSocialUid(providerId, socialUid) + .orElseGet(() -> { + Member newMember = MemberConverter.toMember(dto); + memberRepository.save(newMember); + return newMember; + }); + return new OAuthMember(member, oAuth2User.getAttributes()); + } +} diff --git a/src/main/java/umc/global/security/CustomUserDetailsService.java b/src/main/java/umc/global/security/service/CustomUserDetailsService.java similarity index 92% rename from src/main/java/umc/global/security/CustomUserDetailsService.java rename to src/main/java/umc/global/security/service/CustomUserDetailsService.java index 2a8494d0..bf35614a 100644 --- a/src/main/java/umc/global/security/CustomUserDetailsService.java +++ b/src/main/java/umc/global/security/service/CustomUserDetailsService.java @@ -1,4 +1,4 @@ -package umc.global.security; +package umc.global.security.service; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; @@ -9,6 +9,7 @@ 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 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..e3433105 --- /dev/null +++ b/src/main/java/umc/global/security/util/JwtUtil.java @@ -0,0 +1,89 @@ +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.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("${spring.jwt.token.secretKey}") String secret, + @Value("${spring.jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + 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()) + .claim("role", authorities) + .claim("email", member.getUsername()) + .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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 79a562ab..b882c063 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,17 +3,43 @@ spring: name: "umc10th" # "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 ??? ?? ?? ??? + format_sql: true + + jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "http://localhost:8080/login/oauth2/code/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