From 552ad4b59157c9aaaaebfeb001a8b355b1f5808b Mon Sep 17 00:00:00 2001 From: zeoueon Date: Thu, 27 Nov 2025 11:26:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20MissionController=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=EC=97=90=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가독성을 고려해 컨트롤러 구현체에도 파라미터 검증 어노테이션을 적용함. --- .../domain/misson/controller/MissionController.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/umc/domain/misson/controller/MissionController.java b/src/main/java/umc/domain/misson/controller/MissionController.java index 0e9c9d8..7ec4eb7 100644 --- a/src/main/java/umc/domain/misson/controller/MissionController.java +++ b/src/main/java/umc/domain/misson/controller/MissionController.java @@ -1,5 +1,6 @@ package umc.domain.misson.controller; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -12,6 +13,7 @@ import umc.domain.misson.exception.code.MissionSuccessCode; import umc.domain.misson.service.command.MissionCommandService; import umc.domain.misson.service.query.MissionQueryService; +import umc.global.annotation.PageParam; import umc.global.apiPayload.ApiResponse; @RestController @@ -24,8 +26,8 @@ public class MissionController implements MissionControllerDocs { @GetMapping("/missions") @Override public ApiResponse getMissionsByStore( - @RequestParam String storeName, - @RequestParam Integer page + @RequestParam @NotBlank String storeName, + @RequestParam @PageParam Integer page ) { MissionSuccessCode code = MissionSuccessCode.FOUND; return ApiResponse.onSuccess(code, missionQueryService.findMissionsByStore(storeName, page)); @@ -34,8 +36,8 @@ public ApiResponse getMissionsByStore( @GetMapping("/{memberId}/missions/ongoing") @Override public ApiResponse getOngoingMissions( - @PathVariable Long memberId, - @RequestParam Integer page + @PathVariable @NotBlank Long memberId, + @RequestParam @PageParam Integer page ) { MemberMissionSuccessCode code = MemberMissionSuccessCode.FOUND; return ApiResponse.onSuccess(code, missionQueryService.findOngoingMissions(memberId, page)); From 3a22b097146c0c9039a7c1c49d162373a88688ce Mon Sep 17 00:00:00 2001 From: zeoueon Date: Sun, 21 Dec 2025 14:49:14 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++ .../member/converter/MemberConverter.java | 7 ++- .../member/dto/member/MemberReqDTO.java | 3 ++ .../java/umc/domain/member/entity/Member.java | 6 ++- .../member/repository/MemberRepository.java | 3 ++ .../command/MemberCommandServiceImpl.java | 7 ++- .../umc/global/auth/CustomUserDetails.java | 29 +++++++++++ .../global/auth/CustomUserDetailsService.java | 30 ++++++++++++ src/main/java/umc/global/auth/enums/Role.java | 5 ++ .../umc/global/config/SecurityConfig.java | 49 +++++++++++++++++++ 10 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/main/java/umc/global/auth/CustomUserDetails.java create mode 100644 src/main/java/umc/global/auth/CustomUserDetailsService.java create mode 100644 src/main/java/umc/global/auth/enums/Role.java create mode 100644 src/main/java/umc/global/config/SecurityConfig.java diff --git a/build.gradle b/build.gradle index 82b883c..4ad3891 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,10 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index 1a87034..e8c310f 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -3,6 +3,7 @@ import umc.domain.member.dto.member.MemberReqDTO; import umc.domain.member.dto.member.MemberResDTO; import umc.domain.member.entity.Member; +import umc.global.auth.enums.Role; public class MemberConverter { @@ -13,10 +14,12 @@ public static MemberResDTO.JoinDTO toJoinDTO(Member member) { .build(); } - public static Member toMember(MemberReqDTO.JoinDTO dto) { + public static Member toMember(MemberReqDTO.JoinDTO dto, String password, Role role) { return Member.builder() .name(dto.name()) - .password(dto.password()) + .email(dto.email()) + .password(password) + .role(role) .birth(dto.birth()) .address(dto.address()) .gender(dto.gender()) diff --git a/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java b/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java index 62a818a..1e2c975 100644 --- a/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java +++ b/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java @@ -1,5 +1,6 @@ package umc.domain.member.dto.member; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; @@ -12,6 +13,8 @@ public class MemberReqDTO { public record JoinDTO( @NotBlank String name, + @Email + String email, @NotBlank String password, @NotNull diff --git a/src/main/java/umc/domain/member/entity/Member.java b/src/main/java/umc/domain/member/entity/Member.java index 6f53a86..14b80a0 100644 --- a/src/main/java/umc/domain/member/entity/Member.java +++ b/src/main/java/umc/domain/member/entity/Member.java @@ -23,6 +23,7 @@ import umc.domain.member.entity.mapping.MemberMission; import umc.domain.member.enums.Gender; import umc.domain.member.enums.SnsType; +import umc.global.auth.enums.Role; import umc.global.entity.BaseEntity; @Entity @@ -46,7 +47,10 @@ public class Member extends BaseEntity { @Column(name = "password", nullable = false, length = 255) private String password; - @Column(name = "email", length = 255) + @Enumerated(EnumType.STRING) + private Role role; + + @Column(name = "email", nullable = false, length = 255) private String email; @Column(name = "phone_number", length = 11) diff --git a/src/main/java/umc/domain/member/repository/MemberRepository.java b/src/main/java/umc/domain/member/repository/MemberRepository.java index 3b0017f..e1dd951 100644 --- a/src/main/java/umc/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package umc.domain.member.repository; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,4 +22,6 @@ public interface MemberRepository extends JpaRepository { where m.id = :id """) MyPageDto findMyPageById(@Param("id") long id); + + Optional findByEmail(String email); } diff --git a/src/main/java/umc/domain/member/service/command/MemberCommandServiceImpl.java b/src/main/java/umc/domain/member/service/command/MemberCommandServiceImpl.java index 7eff752..7cc444a 100644 --- a/src/main/java/umc/domain/member/service/command/MemberCommandServiceImpl.java +++ b/src/main/java/umc/domain/member/service/command/MemberCommandServiceImpl.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import umc.domain.member.converter.MemberConverter; import umc.domain.member.dto.member.MemberReqDTO; @@ -13,6 +14,7 @@ import umc.domain.member.repository.FoodRepository; import umc.domain.member.repository.MemberFoodRepository; import umc.domain.member.repository.MemberRepository; +import umc.global.auth.enums.Role; @Service @RequiredArgsConstructor @@ -21,12 +23,15 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; private final FoodRepository foodRepository; private final MemberFoodRepository memberFoodRepository; + private final PasswordEncoder passwordEncoder; @Override public MemberResDTO.JoinDTO signUp( MemberReqDTO.JoinDTO dto ) { - Member member = MemberConverter.toMember(dto); + String salt = passwordEncoder.encode(dto.password()); + + Member member = MemberConverter.toMember(dto, salt, Role.ROLE_USER); memberRepository.save(member); if (dto.preferCategory().size() > 1) { diff --git a/src/main/java/umc/global/auth/CustomUserDetails.java b/src/main/java/umc/global/auth/CustomUserDetails.java new file mode 100644 index 0000000..0235320 --- /dev/null +++ b/src/main/java/umc/global/auth/CustomUserDetails.java @@ -0,0 +1,29 @@ +package umc.global.auth; + +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import umc.domain.member.entity.Member; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(() -> member.getRole().toString()); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/src/main/java/umc/global/auth/CustomUserDetailsService.java b/src/main/java/umc/global/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..2a37348 --- /dev/null +++ b/src/main/java/umc/global/auth/CustomUserDetailsService.java @@ -0,0 +1,30 @@ +package umc.global.auth; + +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; +import umc.domain.member.entity.Member; +import umc.domain.member.exception.member.MemberException; +import umc.domain.member.exception.member.code.MemberErrorCode; +import umc.domain.member.repository.MemberRepository; + + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + // 검증할 Member 조회 + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + // CustomUserDetails 반환 + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/umc/global/auth/enums/Role.java b/src/main/java/umc/global/auth/enums/Role.java new file mode 100644 index 0000000..8232130 --- /dev/null +++ b/src/main/java/umc/global/auth/enums/Role.java @@ -0,0 +1,5 @@ +package umc.global.auth.enums; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java new file mode 100644 index 0000000..e587a17 --- /dev/null +++ b/src/main/java/umc/global/config/SecurityConfig.java @@ -0,0 +1,49 @@ +package umc.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +public class SecurityConfig { + + private final String[] allowUris = { + "/auth/members/signup", + // "/swagger-ui/**", + // "/swagger-resources/**", + // "/v3/api-docs/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/swagger-ui/index.html").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + .csrf(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} From dadd774ac638da07182795a18f7fd55ea8b10bd5 Mon Sep 17 00:00:00 2001 From: zeoueon Date: Sun, 21 Dec 2025 15:59:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - build.gradle | 6 ++ .../member/controller/MemberController.java | 16 ++++ .../member/converter/MemberConverter.java | 7 ++ .../member/dto/member/MemberReqDTO.java | 9 ++ .../member/dto/member/MemberResDTO.java | 8 ++ .../member/code/MemberErrorCode.java | 4 +- .../service/query/MemberQueryService.java | 5 + .../service/query/MemberQueryServiceImpl.java | 36 +++++++ .../auth/AuthenticationEntryPointImpl.java | 32 +++++++ .../java/umc/global/auth/JwtAuthFilter.java | 54 +++++++++++ src/main/java/umc/global/auth/JwtUtil.java | 93 +++++++++++++++++++ .../umc/global/config/SecurityConfig.java | 38 ++++++-- src/main/resources/application.yml | 8 +- 14 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 src/main/java/umc/global/auth/AuthenticationEntryPointImpl.java create mode 100644 src/main/java/umc/global/auth/JwtAuthFilter.java create mode 100644 src/main/java/umc/global/auth/JwtUtil.java diff --git a/.gitignore b/.gitignore index c2065bc..85b2a24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - ### STS ### .apt_generated .classpath diff --git a/build.gradle b/build.gradle index 4ad3891..5d2fc73 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,12 @@ 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' } tasks.named('test') { diff --git a/src/main/java/umc/domain/member/controller/MemberController.java b/src/main/java/umc/domain/member/controller/MemberController.java index a58874d..5cfa269 100644 --- a/src/main/java/umc/domain/member/controller/MemberController.java +++ b/src/main/java/umc/domain/member/controller/MemberController.java @@ -78,4 +78,20 @@ public ApiResponse> getAvailableMissions( return ApiResponse.onSuccess(GeneralSuccessCode.OK, missionList); } + + // 회원가입 + @PostMapping("/sign-up") + public ApiResponse signUp2( + @RequestBody @Valid MemberReqDTO.JoinDTO dto + ) { + return ApiResponse.onSuccess(MemberSuccessCode.FOUND, memberCommandService.signUp(dto)); + } + + // 로그인 + @PostMapping("/login") + public ApiResponse login2( + @RequestBody @Valid MemberReqDTO.LoginDTO dto + ) { + return ApiResponse.onSuccess(MemberSuccessCode.FOUND, memberQueryService.login(dto)); + } } diff --git a/src/main/java/umc/domain/member/converter/MemberConverter.java b/src/main/java/umc/domain/member/converter/MemberConverter.java index e8c310f..7798ce2 100644 --- a/src/main/java/umc/domain/member/converter/MemberConverter.java +++ b/src/main/java/umc/domain/member/converter/MemberConverter.java @@ -25,4 +25,11 @@ public static Member toMember(MemberReqDTO.JoinDTO dto, String password, Role ro .gender(dto.gender()) .build(); } + + public static MemberResDTO.LoginDTO toLoginDTO(Member member, String accessToken) { + return MemberResDTO.LoginDTO.builder() + .memberId(member.getId()) + .accessToken(accessToken) + .build(); + } } diff --git a/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java b/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java index 1e2c975..fa299d0 100644 --- a/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java +++ b/src/main/java/umc/domain/member/dto/member/MemberReqDTO.java @@ -27,4 +27,13 @@ public record JoinDTO( List preferCategory ) { } + + // 로그인 + public record LoginDTO( + @NotBlank + String email, + @NotBlank + String password + ) { + } } diff --git a/src/main/java/umc/domain/member/dto/member/MemberResDTO.java b/src/main/java/umc/domain/member/dto/member/MemberResDTO.java index c8e6989..a1cae72 100644 --- a/src/main/java/umc/domain/member/dto/member/MemberResDTO.java +++ b/src/main/java/umc/domain/member/dto/member/MemberResDTO.java @@ -11,4 +11,12 @@ public record JoinDTO( LocalDateTime createdAt ) { } + + // 로그인 + @Builder + public record LoginDTO( + Long memberId, + String accessToken + ) { + } } diff --git a/src/main/java/umc/domain/member/exception/member/code/MemberErrorCode.java b/src/main/java/umc/domain/member/exception/member/code/MemberErrorCode.java index 3e0ce9a..841a64f 100644 --- a/src/main/java/umc/domain/member/exception/member/code/MemberErrorCode.java +++ b/src/main/java/umc/domain/member/exception/member/code/MemberErrorCode.java @@ -12,7 +12,9 @@ public enum MemberErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾지 못했습니다."), - ; + INVALID(HttpStatus.UNAUTHORIZED, + "MEMBER401_1", + "비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/umc/domain/member/service/query/MemberQueryService.java b/src/main/java/umc/domain/member/service/query/MemberQueryService.java index 47433dd..511aa1d 100644 --- a/src/main/java/umc/domain/member/service/query/MemberQueryService.java +++ b/src/main/java/umc/domain/member/service/query/MemberQueryService.java @@ -1,9 +1,12 @@ package umc.domain.member.service.query; +import jakarta.validation.Valid; import org.springframework.data.domain.Pageable; import umc.domain.member.dto.MissionChallengeListDto; import umc.domain.member.dto.MissionListDto; import umc.domain.member.dto.MyPageDto; +import umc.domain.member.dto.member.MemberReqDTO; +import umc.domain.member.dto.member.MemberResDTO; import umc.domain.member.enums.Status; import umc.global.dto.PageResponse; @@ -18,4 +21,6 @@ PageResponse getAvailableMissions(Long memberId, String regionName, Long lastMissionId, Pageable pageable); + + MemberResDTO.LoginDTO login(MemberReqDTO.@Valid LoginDTO dto); } diff --git a/src/main/java/umc/domain/member/service/query/MemberQueryServiceImpl.java b/src/main/java/umc/domain/member/service/query/MemberQueryServiceImpl.java index f00b5ed..7369ac2 100644 --- a/src/main/java/umc/domain/member/service/query/MemberQueryServiceImpl.java +++ b/src/main/java/umc/domain/member/service/query/MemberQueryServiceImpl.java @@ -1,15 +1,25 @@ package umc.domain.member.service.query; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import umc.domain.member.converter.MemberConverter; import umc.domain.member.dto.MissionChallengeListDto; import umc.domain.member.dto.MissionListDto; import umc.domain.member.dto.MyPageDto; +import umc.domain.member.dto.member.MemberReqDTO; +import umc.domain.member.dto.member.MemberResDTO; +import umc.domain.member.entity.Member; import umc.domain.member.enums.Status; +import umc.domain.member.exception.member.MemberException; +import umc.domain.member.exception.member.code.MemberErrorCode; import umc.domain.member.repository.MemberRepository; import umc.domain.member.repository.membermission.MemberMissionRepository; +import umc.global.auth.CustomUserDetails; +import umc.global.auth.JwtUtil; import umc.global.dto.PageResponse; @Service @@ -18,6 +28,8 @@ public class MemberQueryServiceImpl implements MemberQueryService { private final MemberRepository memberRepository; private final MemberMissionRepository memberMissionRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder encoder; @Override public MyPageDto getMyPage(Long memberId) { @@ -43,5 +55,29 @@ public PageResponse getAvailableMissions(Long memberId, .findAvailableMissionsByMemberIdAndRegion(regionName, memberId, lastMissionId, pageable); return PageResponse.of(page); } + + @Override + public MemberResDTO.LoginDTO login( + MemberReqDTO.@Valid LoginDTO dto + ) { + + // Member 조회 + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + + // 비밀번호 검증 + if (!encoder.matches(dto.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID); + } + + // JWT 토큰 발급용 UserDetails + CustomUserDetails userDetails = new CustomUserDetails(member); + + // 엑세스 토큰 발급 + String accessToken = jwtUtil.createAccessToken(userDetails); + + // DTO 조립 + return MemberConverter.toLoginDTO(member, accessToken); + } } diff --git a/src/main/java/umc/global/auth/AuthenticationEntryPointImpl.java b/src/main/java/umc/global/auth/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..471d2e1 --- /dev/null +++ b/src/main/java/umc/global/auth/AuthenticationEntryPointImpl.java @@ -0,0 +1,32 @@ +package umc.global.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import umc.global.apiPayload.ApiResponse; +import umc.global.apiPayload.code.GeneralErrorCode; + +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse errorResponse = ApiResponse.onFailure( + GeneralErrorCode.UNAUTHORIZED, + null + ); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/src/main/java/umc/global/auth/JwtAuthFilter.java b/src/main/java/umc/global/auth/JwtAuthFilter.java new file mode 100644 index 0000000..ec39d40 --- /dev/null +++ b/src/main/java/umc/global/auth/JwtAuthFilter.java @@ -0,0 +1,54 @@ +package umc.global.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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; + +@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 { + + // 토큰 가져오기 + 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 email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/umc/global/auth/JwtUtil.java b/src/main/java/umc/global/auth/JwtUtil.java new file mode 100644 index 0000000..c2d782d --- /dev/null +++ b/src/main/java/umc/global/auth/JwtUtil.java @@ -0,0 +1,93 @@ +package umc.global.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${JWT_SECRET_KEY}") String secret, + @Value("${JWT_EXPIRATION_ACCESS}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, 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(CustomUserDetails user, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(user.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", user.getUsername()) + .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); + } +} \ No newline at end of file diff --git a/src/main/java/umc/global/config/SecurityConfig.java b/src/main/java/umc/global/config/SecurityConfig.java index e587a17..46361dc 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; @@ -7,17 +8,28 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import umc.global.auth.AuthenticationEntryPointImpl; +import umc.global.auth.CustomUserDetailsService; +import umc.global.auth.JwtAuthFilter; +import umc.global.auth.JwtUtil; @EnableWebSecurity @Configuration +@RequiredArgsConstructor public class SecurityConfig { + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final String[] allowUris = { "/auth/members/signup", - // "/swagger-ui/**", - // "/swagger-resources/**", - // "/v3/api-docs/**", + "/login", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", }; @Bean @@ -28,16 +40,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/swagger-ui/index.html").hasRole("ADMIN") .anyRequest().authenticated() ) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + .formLogin(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .permitAll() - ); + ) + .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) + ; return http.build(); } @@ -46,4 +58,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a9401f..1e04e20 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,10 @@ spring: ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true # ???? SQL ??? ?? ?? ??? + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: ${JWT_EXPIRATION_ACCESS} \ No newline at end of file