diff --git a/build.gradle b/build.gradle index 4b39965..d4d69f9 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,16 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.13' + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' } diff --git a/src/main/java/com/example/umc9th2/domain/User/controller/UserController.java b/src/main/java/com/example/umc9th2/domain/User/controller/UserController.java index 657e475..61ed324 100644 --- a/src/main/java/com/example/umc9th2/domain/User/controller/UserController.java +++ b/src/main/java/com/example/umc9th2/domain/User/controller/UserController.java @@ -5,6 +5,7 @@ import com.example.umc9th2.domain.User.exception.code.UserSuccessCode; import com.example.umc9th2.domain.User.repository.UserRepository; import com.example.umc9th2.domain.User.service.command.UserCommandService; +import com.example.umc9th2.domain.User.service.query.UserQueryService; import com.example.umc9th2.global.apiPayload.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -17,6 +18,7 @@ public class UserController { private final UserCommandService userCommandService; + private final UserQueryService userQueryService; // 회원가입 @PostMapping("/sign-up") @@ -26,4 +28,15 @@ public ApiResponse signUp( return ApiResponse.onSuccess(UserSuccessCode.FOUND, userCommandService.signUp(dto)); } + // 로그인 + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid UserReqDTO.LoginDTO dto + ){ + return ApiResponse.onSuccess( + UserSuccessCode.FOUND, + userQueryService.login(dto) + ); + + } } diff --git a/src/main/java/com/example/umc9th2/domain/User/converter/UserConverter.java b/src/main/java/com/example/umc9th2/domain/User/converter/UserConverter.java index 87fc760..81180e8 100644 --- a/src/main/java/com/example/umc9th2/domain/User/converter/UserConverter.java +++ b/src/main/java/com/example/umc9th2/domain/User/converter/UserConverter.java @@ -3,6 +3,8 @@ import com.example.umc9th2.domain.User.dto.UserReqDTO; import com.example.umc9th2.domain.User.dto.UserResDTO; import com.example.umc9th2.domain.User.entity.User; +import com.example.umc9th2.domain.User.enums.OauthProvider; +import com.example.umc9th2.global.auth.enums.Role; public class UserConverter { @@ -15,19 +17,33 @@ public static UserResDTO.JoinDTO toJoinDTO(User user) { } // DTO -> Entity - public static User toUser(UserReqDTO.JoinDTO dto) { - + public static User toUser( + UserReqDTO.JoinDTO dto, + String password, + Role role + ) { return User.builder() .name(dto.name()) + .email(dto.email()) // 추가 + .password(password) // 추가 (암호화된 비밀번호) + .role(role) // 추가 .birth(dto.birth()) .gender(dto.gender()) - - // RabbitConnectionDetails.Address → String 변환 - .address(dto.address() != null - ? dto.address().toString() - : null) + .address( + dto.address() != null + ? dto.address().toString() + : null + ) .specAddress(dto.specAddress()) + .oauthProvider(OauthProvider.LOCAL) + .build(); + } + public static UserResDTO.LoginDTO LoginDTO(User user, String accessToken) { + return UserResDTO.LoginDTO.builder() + .userId(user.getUserId()) + .accessToken(accessToken) .build(); } + } diff --git a/src/main/java/com/example/umc9th2/domain/User/dto/UserReqDTO.java b/src/main/java/com/example/umc9th2/domain/User/dto/UserReqDTO.java index 4058427..0f5b93e 100644 --- a/src/main/java/com/example/umc9th2/domain/User/dto/UserReqDTO.java +++ b/src/main/java/com/example/umc9th2/domain/User/dto/UserReqDTO.java @@ -2,6 +2,7 @@ import com.example.umc9th2.domain.User.enums.Gender; import com.example.umc9th2.global.annotation.ExistFoods; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import org.springframework.boot.autoconfigure.amqp.RabbitConnectionDetails; @@ -9,22 +10,15 @@ import java.time.LocalDate; import java.util.List; -//public class UserReqDTO { -// -// public record JoinDTO( -// String name, -// Gender gender, -// LocalDate birth, -// RabbitConnectionDetails.Address address, -// String specAddress, -// List preferCategory -// ){} -//} public class UserReqDTO { public record JoinDTO( @NotBlank String name, + @Email + String email, // 추가된 속성 + @NotBlank + String password, // 추가된 속성 @NotNull Gender gender, @NotNull @@ -36,4 +30,13 @@ public record JoinDTO( @ExistFoods List preferCategory ){} + + // 로그인 + public record LoginDTO( + @NotBlank + String email, + @NotBlank + String password + ){} + } diff --git a/src/main/java/com/example/umc9th2/domain/User/dto/UserResDTO.java b/src/main/java/com/example/umc9th2/domain/User/dto/UserResDTO.java index 28e2b55..ab77a98 100644 --- a/src/main/java/com/example/umc9th2/domain/User/dto/UserResDTO.java +++ b/src/main/java/com/example/umc9th2/domain/User/dto/UserResDTO.java @@ -11,4 +11,11 @@ public record JoinDTO( Long memberId, LocalDateTime createAt ){} + + // 로그인 + @Builder + public record LoginDTO( + Long userId, + String accessToken + ){} } diff --git a/src/main/java/com/example/umc9th2/domain/User/entity/User.java b/src/main/java/com/example/umc9th2/domain/User/entity/User.java index 27e391e..126b519 100644 --- a/src/main/java/com/example/umc9th2/domain/User/entity/User.java +++ b/src/main/java/com/example/umc9th2/domain/User/entity/User.java @@ -5,6 +5,7 @@ import com.example.umc9th2.domain.User.entity.mapping.UserTerm; import com.example.umc9th2.domain.User.enums.Gender; import com.example.umc9th2.domain.User.enums.OauthProvider; +import com.example.umc9th2.global.auth.enums.Role; import jakarta.persistence.*; import lombok.*; import org.springframework.data.annotation.CreatedDate; @@ -44,6 +45,12 @@ public class User { @Column(name = "email", nullable = false, length = 100, unique = true) private String email;//이메일 + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + @Enumerated(EnumType.STRING)//소셜로그인 @Column(name = "oauthprovider", nullable = false, length = 20) private OauthProvider oauthProvider; // KAKAO, NAVER, GOOGLE diff --git a/src/main/java/com/example/umc9th2/domain/User/entity/mapping/UserMission.java b/src/main/java/com/example/umc9th2/domain/User/entity/mapping/UserMission.java index 6d97c3a..ba42e1b 100644 --- a/src/main/java/com/example/umc9th2/domain/User/entity/mapping/UserMission.java +++ b/src/main/java/com/example/umc9th2/domain/User/entity/mapping/UserMission.java @@ -13,6 +13,8 @@ @Builder public class UserMission { + //미션 진행중, 완료 상태, 진행전인 것도 함께 넘겨줘야 함 + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long memberMissionId;//PK diff --git a/src/main/java/com/example/umc9th2/domain/User/enums/OauthProvider.java b/src/main/java/com/example/umc9th2/domain/User/enums/OauthProvider.java index 5ba528d..62318a8 100644 --- a/src/main/java/com/example/umc9th2/domain/User/enums/OauthProvider.java +++ b/src/main/java/com/example/umc9th2/domain/User/enums/OauthProvider.java @@ -1,5 +1,5 @@ package com.example.umc9th2.domain.User.enums; public enum OauthProvider { - KAKAO, NAVER, GOOGLE, APPLE + LOCAL, KAKAO, NAVER, GOOGLE, APPLE } diff --git a/src/main/java/com/example/umc9th2/domain/User/exception/UserException.java b/src/main/java/com/example/umc9th2/domain/User/exception/UserException.java index b79a296..8969987 100644 --- a/src/main/java/com/example/umc9th2/domain/User/exception/UserException.java +++ b/src/main/java/com/example/umc9th2/domain/User/exception/UserException.java @@ -1,7 +1,18 @@ package com.example.umc9th2.domain.User.exception; +import com.example.umc9th2.domain.User.exception.code.UserErrorCode; + public class UserException extends RuntimeException { - public UserException(String message) { - super(message); + + private final UserErrorCode errorCode; + + public UserException(UserErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public UserErrorCode getErrorCode() { + return errorCode; } } + diff --git a/src/main/java/com/example/umc9th2/domain/User/exception/code/UserErrorCode.java b/src/main/java/com/example/umc9th2/domain/User/exception/code/UserErrorCode.java index b605746..2643493 100644 --- a/src/main/java/com/example/umc9th2/domain/User/exception/code/UserErrorCode.java +++ b/src/main/java/com/example/umc9th2/domain/User/exception/code/UserErrorCode.java @@ -9,6 +9,12 @@ @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { + INVALID_PASSWORD( + HttpStatus.UNAUTHORIZED, + "USER401_1", + "비밀번호가 올바르지 않습니다." + ), + NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾지 못했습니다."), diff --git a/src/main/java/com/example/umc9th2/domain/User/repository/UserRepository.java b/src/main/java/com/example/umc9th2/domain/User/repository/UserRepository.java index 00d7e5c..9b9a998 100644 --- a/src/main/java/com/example/umc9th2/domain/User/repository/UserRepository.java +++ b/src/main/java/com/example/umc9th2/domain/User/repository/UserRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc9th2/domain/User/service/command/UserCommandServiceImpl.java b/src/main/java/com/example/umc9th2/domain/User/service/command/UserCommandServiceImpl.java index e1e5fda..1f1c6d5 100644 --- a/src/main/java/com/example/umc9th2/domain/User/service/command/UserCommandServiceImpl.java +++ b/src/main/java/com/example/umc9th2/domain/User/service/command/UserCommandServiceImpl.java @@ -5,20 +5,20 @@ import com.example.umc9th2.domain.User.dto.UserResDTO; import com.example.umc9th2.domain.User.entity.User; import com.example.umc9th2.domain.User.entity.mapping.UserFood; +import com.example.umc9th2.global.auth.enums.Role; import com.example.umc9th2.domain.Food.entity.Food; import com.example.umc9th2.domain.Food.exception.FoodException; import com.example.umc9th2.domain.Food.exception.code.FoodErrorCode; - import com.example.umc9th2.domain.User.repository.UserRepository; import com.example.umc9th2.domain.User.repository.UserFoodRepository; import com.example.umc9th2.domain.Food.repository.FoodRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -26,17 +26,24 @@ public class UserCommandServiceImpl implements UserCommandService { private final UserRepository userRepository; private final UserFoodRepository userFoodRepository; - - // FoodRepository는 Food 도메인에 있어야만 정상 import 됨!! private final FoodRepository foodRepository; + // Password Encoder 추가 + private final PasswordEncoder passwordEncoder; + + // 회원가입 @Override @Transactional public UserResDTO.JoinDTO signUp(UserReqDTO.JoinDTO dto) { - User user = UserConverter.toUser(dto); + // 1. 비밀번호 암호화 (Salted Password) + String encodedPassword = passwordEncoder.encode(dto.password()); + + // 2. 사용자 생성 (기본 권한: ROLE_USER) + User user = UserConverter.toUser(dto, encodedPassword, Role.ROLE_USER); userRepository.save(user); + // 3. 선호 음식 카테고리 매핑 if (dto.preferCategory() != null && !dto.preferCategory().isEmpty()) { List userFoodList = dto.preferCategory().stream() @@ -44,7 +51,9 @@ public UserResDTO.JoinDTO signUp(UserReqDTO.JoinDTO dto) { .user(user) .food( foodRepository.findById(id) - .orElseThrow(() -> new FoodException(FoodErrorCode.NOT_FOUND)) + .orElseThrow(() -> + new FoodException(FoodErrorCode.NOT_FOUND) + ) ) .build()) .toList(); @@ -52,6 +61,7 @@ public UserResDTO.JoinDTO signUp(UserReqDTO.JoinDTO dto) { userFoodRepository.saveAll(userFoodList); } + // 4. 응답 DTO 반환 return UserConverter.toJoinDTO(user); } } diff --git a/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryService.java b/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryService.java index 588a9d1..006d7cb 100644 --- a/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryService.java +++ b/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryService.java @@ -1,4 +1,10 @@ package com.example.umc9th2.domain.User.service.query; +import com.example.umc9th2.domain.User.dto.UserReqDTO; +import com.example.umc9th2.domain.User.dto.UserResDTO; +import jakarta.validation.Valid; + public interface UserQueryService { + UserResDTO.LoginDTO login(UserReqDTO.LoginDTO dto); } + diff --git a/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryServiceImpl.java b/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryServiceImpl.java index a899610..72b516c 100644 --- a/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryServiceImpl.java +++ b/src/main/java/com/example/umc9th2/domain/User/service/query/UserQueryServiceImpl.java @@ -1,4 +1,41 @@ package com.example.umc9th2.domain.User.service.query; -public class UserQueryServiceImpl { +import com.example.umc9th2.domain.User.dto.UserReqDTO; +import com.example.umc9th2.domain.User.dto.UserResDTO; +import com.example.umc9th2.domain.User.entity.User; +import com.example.umc9th2.domain.User.exception.UserException; +import com.example.umc9th2.domain.User.exception.code.UserErrorCode; +import com.example.umc9th2.domain.User.repository.UserRepository; +import com.example.umc9th2.global.auth.jwt.JwtUtil; +import com.example.umc9th2.global.auth.principal.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import com.example.umc9th2.domain.User.converter.UserConverter; +//로그인 성공시 jwt 발급, 클라이언트는 이후 요청에 토큰 사용 +@Service +@RequiredArgsConstructor +public class UserQueryServiceImpl implements UserQueryService { + + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder encoder; + + @Override + public UserResDTO.LoginDTO login(UserReqDTO.LoginDTO dto) { + //이메일로 사용자 조회 + User user = userRepository.findByEmail(dto.email()) + .orElseThrow(() -> new UserException(UserErrorCode.NOT_FOUND)); + //비밀번호 검증 + if (!encoder.matches(dto.password(), user.getPassword())) { + throw new UserException(UserErrorCode.INVALID_PASSWORD); + } + //jwt 발급용 userdetails 생성 + CustomUserDetails userDetails = new CustomUserDetails(user); + //accress token 생성 + String accessToken = jwtUtil.createAccessToken(userDetails); + //토큰을 포함한 응답 반환 + return UserConverter.LoginDTO(user, accessToken); + } } diff --git a/src/main/java/com/example/umc9th2/domain/mission/controller/MissionController.java b/src/main/java/com/example/umc9th2/domain/mission/controller/MissionController.java index 780d039..6e5c37f 100644 --- a/src/main/java/com/example/umc9th2/domain/mission/controller/MissionController.java +++ b/src/main/java/com/example/umc9th2/domain/mission/controller/MissionController.java @@ -20,6 +20,7 @@ public class MissionController implements MissionControllerDocs { private final MissionQueryService missionQueryService; //특정 가게 미션 목록 조회 + //api/missions에 특정 가게 미션 목록 조회가 맞는 url인지 다시 생각해보기 직관적이지 않은듯 @GetMapping @Override public ApiResponse getStoreMissions( diff --git a/src/main/java/com/example/umc9th2/domain/review/converter/ReviewConverter.java b/src/main/java/com/example/umc9th2/domain/review/converter/ReviewConverter.java index 5f98449..3aed762 100644 --- a/src/main/java/com/example/umc9th2/domain/review/converter/ReviewConverter.java +++ b/src/main/java/com/example/umc9th2/domain/review/converter/ReviewConverter.java @@ -43,7 +43,7 @@ public static ReviewResponseDto.ReviewPreViewListDTO toReviewPreviewListDTO(Page ReviewResponseDto.ReviewPreViewDTO previewDTO = null; if (!result.getContent().isEmpty()) { - // 첫 번째 요소만 DTO로 변환 + // 첫 번째 요소만 DTO로 변환(전체 반환하게 다시) previewDTO = toReviewPreviewDTO(result.getContent().get(0)); } diff --git a/src/main/java/com/example/umc9th2/domain/review/service/query/ReviewQueryServiceImpl.java b/src/main/java/com/example/umc9th2/domain/review/service/query/ReviewQueryServiceImpl.java index dda33f7..152f0ae 100644 --- a/src/main/java/com/example/umc9th2/domain/review/service/query/ReviewQueryServiceImpl.java +++ b/src/main/java/com/example/umc9th2/domain/review/service/query/ReviewQueryServiceImpl.java @@ -24,7 +24,7 @@ public class ReviewQueryServiceImpl implements ReviewQueryService { private static final int PAGE_SIZE = 10; - @Override + @Override//(워크북-실습예제 페이징 -1 안 해줌 수정) public ReviewResponseDto.ReviewPreViewListDTO findReview(String storeName, Integer page) { // 가게 검색 + 예외 발생시 처리 @@ -50,6 +50,7 @@ public List getFilteredReviews(Long userId, String storeName, //내가 작성한 리뷰 조회 //page는 1이상 0, 음수 고려 x //사용자의 모든 리뷰를 가져오고 컨버터로 dto변환 + //findbyuser_userid->findByAllByUser로 이름 수정 @Override public ReviewResponseDto.ReviewPreViewListDTO getMyReviews(Long userId, Integer page) { diff --git a/src/main/java/com/example/umc9th2/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/umc9th2/global/apiPayload/code/GeneralErrorCode.java index ce53030..b192e5d 100644 --- a/src/main/java/com/example/umc9th2/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/example/umc9th2/global/apiPayload/code/GeneralErrorCode.java @@ -18,6 +18,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ "예기치 않은 서버 에러가 발생했습니다."), ; + // BAD_REQUEST(HttpStatus.BAD_REQUEST, // "COMMON400_1", // "잘못된 요청입니다."), diff --git a/src/main/java/com/example/umc9th2/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/example/umc9th2/global/apiPayload/handler/GeneralExceptionAdvice.java index 75e4b1a..6e9c6c2 100644 --- a/src/main/java/com/example/umc9th2/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/example/umc9th2/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -61,6 +61,7 @@ protected ResponseEntity handleMethodArgumentNotValidException(MethodArgument .body(ApiResponse.onFailure(code, errors)); } + //valid 오류 난거 여기다가 구현 } diff --git a/src/main/java/com/example/umc9th2/global/auth/entrypoint/AuthenticationEntryPointImpl.java b/src/main/java/com/example/umc9th2/global/auth/entrypoint/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..f0a5fa0 --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/entrypoint/AuthenticationEntryPointImpl.java @@ -0,0 +1,33 @@ +package com.example.umc9th2.global.auth.entrypoint; + +import com.example.umc9th2.global.apiPayload.ApiResponse; +import com.example.umc9th2.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +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.VALID_FAIL, + null + ); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/src/main/java/com/example/umc9th2/global/auth/enums/Role.java b/src/main/java/com/example/umc9th2/global/auth/enums/Role.java new file mode 100644 index 0000000..f1bcac2 --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.umc9th2.global.auth.enums; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} diff --git a/src/main/java/com/example/umc9th2/global/auth/jwt/JwtAuthFilter.java b/src/main/java/com/example/umc9th2/global/auth/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..ca791e1 --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/jwt/JwtAuthFilter.java @@ -0,0 +1,114 @@ +package com.example.umc9th2.global.auth.jwt; + +import com.example.umc9th2.global.apiPayload.ApiResponse; +import com.example.umc9th2.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th2.global.auth.service.CustomUserDetailsService; +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; +//매 요청마다 jwt 검사, 유효하면 로그인된 사용자로 인식 +@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 { + + // Authorization 헤더에서 토큰 추출 - 토큰 가져오기 + 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); + + + } +} + +//@RequiredArgsConstructor +//public class JwtAuthFilter extends OncePerRequestFilter { +// +// private final JwtUtil jwtUtil; +// private final CustomUserDetailsService customUserDetailsService; +// +// @Override +// protected void doFilterInternal( +// @NonNull HttpServletRequest request, +// @NonNull HttpServletResponse response, +// @NonNull FilterChain filterChain +// ) throws ServletException, IOException { +// +// try { +// // 토큰 가져오기 +// String token = request.getHeader("Authorization"); +// // token이 없거나 Bearer가 아니면 넘기기 +// if (token == null || !token.startsWith("Bearer ")) { +// filterChain.doFilter(request, response); +// return; +// } +// // Bearer이면 추출 +// token = token.replace("Bearer ", ""); +// // AccessToken 검증하기: 올바른 토큰이면 +// if (jwtUtil.isValid(token)) { +// // 토큰에서 이메일 추출 +// String 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); +// } catch (Exception e) { +// response.setContentType("application/json;charset=UTF-8"); +// response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// +// ApiResponse errorResponse = ApiResponse.onFailure( +// GeneralErrorCode.VALID_FAIL, +// null +// ); +// +// ObjectMapper mapper = new ObjectMapper(); +// mapper.writeValue(response.getOutputStream(), errorResponse); +// } +// } +//} diff --git a/src/main/java/com/example/umc9th2/global/auth/jwt/JwtUtil.java b/src/main/java/com/example/umc9th2/global/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..8871d21 --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/jwt/JwtUtil.java @@ -0,0 +1,97 @@ +package com.example.umc9th2.global.auth.jwt; + +import com.example.umc9th2.global.auth.principal.CustomUserDetails; +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; +//로그인 성공시 jwt 발급이후 요청에서 토큰 검증 +@Component +public class JwtUtil { + //JWT 서명에 사용할 비밀키 + private final SecretKey secretKey; + //access token 만료 시간 + private final Duration accessExpiration; + + public JwtUtil(//application.yml에서 값 주입 + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) {//문자열 secretkey ->HMAC-SHA 키로 변환 + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration);//밀리초->Duration + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + //jwt에서 이메일 추출 + public String getEmail(String token) { + try { + //jwt 파싱 후 subject 추출 + 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(); + } + + // jwt 파싱 - 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} diff --git a/src/main/java/com/example/umc9th2/global/auth/principal/CustomUserDetails.java b/src/main/java/com/example/umc9th2/global/auth/principal/CustomUserDetails.java new file mode 100644 index 0000000..5b30d2b --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/principal/CustomUserDetails.java @@ -0,0 +1,76 @@ +package com.example.umc9th2.global.auth.principal; + +import com.example.umc9th2.domain.User.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +//spring security가 user 엔티티를 인식하도록 변환 +//jwt 검증 시 이메일로 사용자 조회, 인증 객체 생성에 사용 +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + //실제 user 엔티티 + private final User user; + //사용자 권한 반환 + @Override + public Collection getAuthorities() { + return List.of(() -> user.getRole().name()); + } + //암호화된 비밀번호 + @Override + public String getPassword() { + return user.getPassword(); + } + //로그인 ID(email) + @Override + public String getUsername() { + return user.getEmail(); + } + //계정 상태 관련 + @Override + public boolean isAccountNonExpired() { return true; } + + @Override + public boolean isAccountNonLocked() { return true; } + + @Override + public boolean isCredentialsNonExpired() { return true; } + + @Override + public boolean isEnabled() { return true; } +} + +//user 엔티티를 userdetails로 변환 +//@RequiredArgsConstructor +//public class CustomUserDetails implements UserDetails { + +// 실제 DB에 저장된 Member 엔티티 +// private final Member member; +// +// //사용자 권한 반환 +// @Override +// public Collection getAuthorities() { +// // ROLE_USER, ROLE_ADMIN 등 +// return List.of(() -> member.getRole().toString()); +// } +// +// // 암호화된 비밀번호 반환 +// @Override +// public String getPassword() { +// return member.getPassword(); +// } +// +// // 로그인 ID (email) +// @Override +// public String getUsername() { +// return member.getEmail(); +// } +// +// // 계정 상태 +// @Override public boolean isAccountNonExpired() { return true; } +// @Override public boolean isAccountNonLocked() { return true; } +// @Override public boolean isCredentialsNonExpired() { return true; } +// @Override public boolean isEnabled() { return true; } +//} diff --git a/src/main/java/com/example/umc9th2/global/auth/service/CustomUserDetailsService.java b/src/main/java/com/example/umc9th2/global/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..b6880e7 --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/auth/service/CustomUserDetailsService.java @@ -0,0 +1,36 @@ +package com.example.umc9th2.global.auth.service; + +import com.example.umc9th2.domain.User.entity.User; +import com.example.umc9th2.domain.User.exception.UserException; +import com.example.umc9th2.domain.User.exception.code.UserErrorCode; +import com.example.umc9th2.domain.User.repository.UserRepository; +import com.example.umc9th2.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th2.global.auth.principal.CustomUserDetails; +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; +//로그인 시 사용자 조회 +//로그인 요청이 오면 스프링 시큐리티 자동 호출 +//이메일로 사용자 조회 +//조회된 사용자 정보를 customdetails로 반환 +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + //로그인 시 입력한 이메일로 사용자 조회 + User user = userRepository.findByEmail(username) + .orElseThrow(() -> + new UsernameNotFoundException("존재하지 않는 사용자입니다.") + ); + //user -> userdetails로 변환 + return new CustomUserDetails(user); + } + +} diff --git a/src/main/java/com/example/umc9th2/global/config/SecurityConfig.java b/src/main/java/com/example/umc9th2/global/config/SecurityConfig.java new file mode 100644 index 0000000..1be72fc --- /dev/null +++ b/src/main/java/com/example/umc9th2/global/config/SecurityConfig.java @@ -0,0 +1,216 @@ +package com.example.umc9th2.global.config; + +import com.example.umc9th2.global.auth.entrypoint.AuthenticationEntryPointImpl; +import com.example.umc9th2.global.auth.jwt.JwtAuthFilter; +import com.example.umc9th2.global.auth.jwt.JwtUtil; +import com.example.umc9th2.global.auth.service.CustomUserDetailsService; +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.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + private final String[] allowUris = { + "/login", + "/sign-up", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + // 폼로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // JwtAuthFilter를 UsernamePasswordAuthenticationFilter + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + .csrf(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) + + ; + + return http.build(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } + +} + +//@EnableWebSecurity +//@Configuration +//@RequiredArgsConstructor +//public class SecurityConfig {//JWT 토큰 로직 +// +// private final JwtUtil jwtUtil; +// private final CustomUserDetailsService customUserDetailsService; +// +// private final String[] allowUris = { +// "/login", +// "/swagger-ui/**", +// "/swagger-resources/**", +// "/v3/api-docs/**" +// }; +// +// @Bean +// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +// http +// .authorizeHttpRequests(requests -> requests +// .requestMatchers(allowUris).permitAll() +// .requestMatchers("/admin/**").hasRole("ADMIN") +// .anyRequest().authenticated() +// ) +// // 폼로그인 비활성화 +// .formLogin(AbstractHttpConfigurer::disable) +// // JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가 +// .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) +// .csrf(AbstractHttpConfigurer::disable) +// .logout(logout -> logout +// .logoutUrl("/logout") +// .logoutSuccessUrl("/login?logout") +// .permitAll() +// ); +// +// return http.build(); +// } +// +// @Bean +// public JwtAuthFilter jwtAuthFilter() { +// return new JwtAuthFilter(jwtUtil, customUserDetailsService); +// } +// +// @Bean +// public PasswordEncoder passwordEncoder() { +// return new BCryptPasswordEncoder(); +// } +//} + +//@EnableWebSecurity +//@Configuration +//public class SecurityConfig { +// //세션 관리자 로그인 로직 +// private final String[] allowUris = { +// "/sign-up", +// "/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("/", true) +// .permitAll() +// ) +// .csrf(AbstractHttpConfigurer::disable) +// .logout(logout -> logout +// .logoutUrl("/logout") +// .logoutSuccessUrl("/login?logout") +// .permitAll() +// ); +// +// return http.build(); +// } +// +// @Bean +// public PasswordEncoder passwordEncoder() { +// return new BCryptPasswordEncoder(); +// } +//} + +//@EnableWebSecurity // Spring Security 활성화 +//@Configuration +//public class SecurityConfig { + + // 인증 없이 접근 허용할 URI 목록 + //private final String[] allowUris = { +// "/sign-up", // 회원가입 +// "/swagger-ui/**", // Swagger UI +// "/swagger-resources/**", +// "/v3/api-docs/**" +// }; + +// @Bean +// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +// http +// //인가(Authorization) 설정 +// .authorizeHttpRequests(requests -> requests +// .requestMatchers(allowUris).permitAll() // 허용 URI +// .requestMatchers("/admin/**").hasRole("ADMIN") // 관리자 전용 +// .anyRequest().authenticated() // 나머지는 로그인 필요 +// ) +// +// // Session 기반 로그인 설정 +// .formLogin(form -> form +// // 로그인 성공 시 이동할 페이지 +// .defaultSuccessUrl("/swagger-ui/index.html", true) +// .permitAll() // 로그인 페이지 접근 허용 +// ) +// +// //CSRF 비활성화 +// .csrf(AbstractHttpConfigurer::disable) +// +// // 로그아웃 처리 +// .logout(logout -> logout +// .logoutUrl("/logout") // 로그아웃 요청 URL +// .logoutSuccessUrl("/login?logout")// 로그아웃 후 이동 +// .permitAll() +// ); +// +// return http.build(); +// } + + //비밀번호 암호화 Bean (BCrypt) +// @Bean +// public PasswordEncoder passwordEncoder() { + // return new BCryptPasswordEncoder(); +// } +//} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c3eb4d..a56ca96 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,3 +17,9 @@ spring: properties: hibernate: format_sql: true # ???? SQL ??? ?? ?? ??? + +jwt: + token: + secretKey: ZGh3YWlkc2F2ZXdhZXZ3b2EgMTM5ZXUgMDMxdWMyIHEyMiBAIDAgKTJFVio= + expiration: + access: 14400000 \ No newline at end of file