diff --git a/.gitignore b/.gitignore index 1a97b77c..2c0c9410 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ database/data/ ### REDIS/DATA Directory ### redis/data/ +/src/main/generated/com/haejwo/tripcometrue diff --git a/build.gradle b/build.gradle index caa14d82..f03613e0 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' compileOnly 'org.projectlombok:lombok' // mysql connector @@ -53,6 +55,14 @@ dependencies { // dotenv-java implementation 'io.github.cdimascio:java-dotenv:+' + + //oauth + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + // jjwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2' } jar { diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java index 94730448..d524b67d 100644 --- a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java @@ -1,15 +1,21 @@ package com.haejwo.tripcometrue.domain.member.controller; -import com.haejwo.tripcometrue.domain.member.request.SignUpRequest; -import com.haejwo.tripcometrue.domain.member.response.SignUpResponse; +import com.haejwo.tripcometrue.domain.member.dto.request.SignUpRequestDto; +import com.haejwo.tripcometrue.domain.member.dto.response.SignUpResponseDto; +import com.haejwo.tripcometrue.domain.member.dto.response.TestUserResponseDto; +import com.haejwo.tripcometrue.domain.member.entity.Member; import com.haejwo.tripcometrue.domain.member.service.MemberService; +import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails; import com.haejwo.tripcometrue.global.util.ResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -20,10 +26,35 @@ public class MemberController { private final MemberService memberService; @PostMapping("/signup") - public ResponseEntity> signup( - @Valid @RequestBody SignUpRequest signUpRequest) { - SignUpResponse signupResponse = memberService.signup(signUpRequest); - ResponseDTO response = ResponseDTO.okWithData(signupResponse); - return ResponseEntity.status(response.getCode()).body(response); + public ResponseEntity> signup( + @Valid @RequestBody SignUpRequestDto signUpRequestDto) { + SignUpResponseDto signupResponseDto = memberService.signup(signUpRequestDto); + ResponseDTO response = ResponseDTO.okWithData(signupResponseDto); + return ResponseEntity + .status(response.getCode()) + .body(response); + } + + // Authenticated user 샘플테스트 코드입니다 + @GetMapping("/test/jwt") + public ResponseEntity> test( + @AuthenticationPrincipal PrincipalDetails principalDetails) { + Member member = principalDetails.getMember(); + + TestUserResponseDto testUserResponseDto = TestUserResponseDto.fromEntity(member); + ResponseDTO response = ResponseDTO.okWithData(testUserResponseDto); + return ResponseEntity + .status(response.getCode()) + .body(response); + } + + @GetMapping("/check-duplicated-email") + public ResponseEntity> checkDuplicateEmail( + @RequestParam String email) { + memberService.checkDuplicateEmail(email); + ResponseDTO response = ResponseDTO.ok(); + return ResponseEntity + .status(response.getCode()) + .body(response); } } \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java new file mode 100644 index 00000000..88098c05 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java @@ -0,0 +1,12 @@ +package com.haejwo.tripcometrue.domain.member.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record LoginRequestDto( + @NotNull(message = "email은 필수값입니다") + String email, + @NotNull(message = "password은 필수값입니다") + String password +) { + +} diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java similarity index 89% rename from src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java rename to src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java index e65d952d..2efd48c4 100644 --- a/src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java @@ -1,9 +1,10 @@ -package com.haejwo.tripcometrue.domain.member.request; +package com.haejwo.tripcometrue.domain.member.dto.request; import com.haejwo.tripcometrue.domain.member.entity.Member; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -public record SignUpRequest( + +public record SignUpRequestDto( @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", message = "이메일 형식이 유효하지 않습니다") @NotNull(message = "email은 필수값입니다") diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java new file mode 100644 index 00000000..82a053d3 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java @@ -0,0 +1,18 @@ +package com.haejwo.tripcometrue.domain.member.dto.response; + +import com.haejwo.tripcometrue.domain.member.entity.Member; + +public record LoginResponseDto( + String email, + String name, + String token +) { + + public static LoginResponseDto fromEntity(Member member, String token) { + return new LoginResponseDto( + member.getMemberBase().getEmail(), + member.getMemberBase().getNickname(), + token + ); + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java similarity index 51% rename from src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java rename to src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java index b0fd5e21..d03ea4dd 100644 --- a/src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java @@ -1,18 +1,19 @@ -package com.haejwo.tripcometrue.domain.member.response; +package com.haejwo.tripcometrue.domain.member.dto.response; import com.haejwo.tripcometrue.domain.member.entity.Member; -public record SignUpResponse( +public record SignUpResponseDto( Long memberId, String email, String name ) { - public static SignUpResponse fromEntity(Member member) { - return new SignUpResponse ( - member.getMemberId(), + + public static SignUpResponseDto fromEntity(Member member) { + return new SignUpResponseDto( + member.getId(), member.getMemberBase().getEmail(), member.getMemberBase().getNickname() ); diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java new file mode 100644 index 00000000..3ca11d7c --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java @@ -0,0 +1,22 @@ +package com.haejwo.tripcometrue.domain.member.dto.response; + +import com.haejwo.tripcometrue.domain.member.entity.Member; + +public record TestUserResponseDto( + + String email, + String nickname, + String authority, + String provider + +) { + + public static TestUserResponseDto fromEntity(Member member) { + return new TestUserResponseDto( + member.getMemberBase().getEmail(), + member.getMemberBase().getNickname(), + member.getMemberBase().getAuthority(), + member.getProvider() + ); + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java index 69c9f41c..d99a8059 100644 --- a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java @@ -1,6 +1,7 @@ package com.haejwo.tripcometrue.domain.member.entity; import com.haejwo.tripcometrue.global.entity.BaseTimeEntity; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +19,8 @@ public class Member extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long memberId; + @Column(name = "member_id") + private Long id; @Embedded protected MemberBase memberBase; diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java index f9d52a29..d26399a4 100644 --- a/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java +++ b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java @@ -1,10 +1,10 @@ package com.haejwo.tripcometrue.domain.member.service; +import com.haejwo.tripcometrue.domain.member.dto.request.SignUpRequestDto; +import com.haejwo.tripcometrue.domain.member.dto.response.SignUpResponseDto; import com.haejwo.tripcometrue.domain.member.entity.Member; import com.haejwo.tripcometrue.domain.member.exception.EmailDuplicateException; import com.haejwo.tripcometrue.domain.member.repository.MemberRepository; -import com.haejwo.tripcometrue.domain.member.request.SignUpRequest; -import com.haejwo.tripcometrue.domain.member.response.SignUpResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -18,16 +18,22 @@ public class MemberService { private final MemberRepository memberRepository; private final BCryptPasswordEncoder passwordEncoder; - public SignUpResponse signup(SignUpRequest signUpRequest) { + public SignUpResponseDto signup(SignUpRequestDto signUpRequestDto) { - memberRepository.findByMemberBaseEmail(signUpRequest.email()).ifPresent(user -> { + memberRepository.findByMemberBaseEmail(signUpRequestDto.email()).ifPresent(user -> { throw new EmailDuplicateException(); }); - String encodedPassword = passwordEncoder.encode(signUpRequest.password()); + String encodedPassword = passwordEncoder.encode(signUpRequestDto.password()); - Member newMember = signUpRequest.toEntity(encodedPassword); + Member newMember = signUpRequestDto.toEntity(encodedPassword); memberRepository.save(newMember); - return SignUpResponse.fromEntity(newMember); + return SignUpResponseDto.fromEntity(newMember); + } + + public void checkDuplicateEmail(String email) { + memberRepository.findByMemberBaseEmail(email).ifPresent(user -> { + throw new EmailDuplicateException(); + }); } } \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/config/AppConfig.java b/src/main/java/com/haejwo/tripcometrue/global/config/AppConfig.java index 4cde1bbe..90532544 100644 --- a/src/main/java/com/haejwo/tripcometrue/global/config/AppConfig.java +++ b/src/main/java/com/haejwo/tripcometrue/global/config/AppConfig.java @@ -14,6 +14,7 @@ @Configuration public class AppConfig { + @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/haejwo/tripcometrue/global/exception/GlobalExceptionRestAdvice.java b/src/main/java/com/haejwo/tripcometrue/global/exception/GlobalExceptionRestAdvice.java index 6a81734f..2e02c309 100644 --- a/src/main/java/com/haejwo/tripcometrue/global/exception/GlobalExceptionRestAdvice.java +++ b/src/main/java/com/haejwo/tripcometrue/global/exception/GlobalExceptionRestAdvice.java @@ -1,7 +1,7 @@ package com.haejwo.tripcometrue.global.exception; import com.haejwo.tripcometrue.global.util.ResponseDTO; -import java.util.Map; +import java.util.List; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; @@ -9,7 +9,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -66,14 +65,16 @@ public ResponseEntity> handleValidationExceptions( BindingResult bindingResult = e.getBindingResult(); - Map fieldErrors = bindingResult.getFieldErrors() + List fieldErrors = bindingResult.getFieldErrors() .stream() - .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage)); + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.toList()); log.error(e.getMessage(), e); + String errorMessage = String.join(", ", fieldErrors); + return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ResponseDTO.errorWithMessage(HttpStatus.BAD_REQUEST, - fieldErrors.values().toString().substring(1,fieldErrors.values().toString().length()-1))); + .body(ResponseDTO.errorWithMessage(HttpStatus.BAD_REQUEST, errorMessage)); } } diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..ba3344a0 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,140 @@ +package com.haejwo.tripcometrue.global.jwt; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.haejwo.tripcometrue.domain.member.dto.request.LoginRequestDto; +import com.haejwo.tripcometrue.domain.member.dto.response.LoginResponseDto; +import com.haejwo.tripcometrue.domain.member.entity.Member; +import com.haejwo.tripcometrue.domain.member.repository.MemberRepository; +import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails; +import com.haejwo.tripcometrue.global.util.ResponseDTO; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Component; + +/** + * @author liyusang1 + * @implNote JWT를 이용한 로그인 인증 (Authentication) 코드 + */ +@Component +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private final JwtProvider jwtProvider; + MemberRepository memberRepository; + + public JwtAuthenticationFilter( + AuthenticationManager authenticationManager, + JwtProvider jwtProvider, + MemberRepository memberRepository + ) { + super.setAuthenticationManager(authenticationManager); + this.jwtProvider = jwtProvider; + this.memberRepository = memberRepository; + } + + /** + * 로그인 인증 시도 + */ + @Override + public Authentication attemptAuthentication( + HttpServletRequest request, + HttpServletResponse response + ) throws AuthenticationException { + try { + // 요청된 JSON 데이터를 객체로 파싱 + ObjectMapper objectMapper = new ObjectMapper(); + LoginRequestDto loginRequest = objectMapper.readValue(request.getInputStream(), + LoginRequestDto.class); + + // 로그인할 때 입력한 email과 password를 가지고 authenticationToken를 생성 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + loginRequest.email(), + loginRequest.password(), + new ArrayList<>(List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ); + + return this.getAuthenticationManager().authenticate(authenticationToken); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 인증 성공시 쿠키에 jwt토큰을 담으려면 아래와 같이 바꾸면 됨 + * Cookie cookie = new Cookie(JwtProperties.COOKIE_NAME,token); + * cookie.setMaxAge(JwtProperties.ACCESS_TOKEN_EXPIRATION_TIME / 1000 * 2); + * // setMaxAge는 초단위 + * cookie.setSecure(true); + * cookie.setPath("/"); + * response.addCookie(cookie) + * 발급후 redirect로 이동 클라이언트에게 http 리다이렉션 요청 코드 response.sendRedirect("/"); + */ + @Override + protected void successfulAuthentication( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain, + Authentication authResult + ) throws IOException { + Member member = ((PrincipalDetails) authResult.getPrincipal()).getMember(); + String token = jwtProvider.createToken(member); + + LoginResponseDto loginResponseDto = LoginResponseDto.fromEntity(member, token); + ResponseDTO loginResponse = ResponseDTO.okWithData(loginResponseDto); + + sendJsonResponse(response, loginResponse, HttpStatus.OK); + } + + /** + * 인증실패 + */ + @Override + protected void unsuccessfulAuthentication( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException, ServletException { + String authenticationErrorMessage = getAuthenticationErrorMessage(exception); + + ResponseDTO errorResponse = ResponseDTO.errorWithMessage(HttpStatus.BAD_REQUEST, + authenticationErrorMessage); + sendJsonResponse(response, errorResponse, HttpStatus.BAD_REQUEST); + } + + private void sendJsonResponse(HttpServletResponse response, Object responseData, + HttpStatus httpStatus) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + String jsonResponse = objectMapper.writeValueAsString(responseData); + + response.setStatus(httpStatus.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(jsonResponse); + } + + private String getAuthenticationErrorMessage(AuthenticationException exception) { + if (exception instanceof BadCredentialsException) { + return "이메일 또는 비밀번호 에러"; + } else if (exception instanceof UsernameNotFoundException) { + return "존재하지 않는 유저"; + } else { + return "인증 실패"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthorizationFilter.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthorizationFilter.java new file mode 100644 index 00000000..83b3398b --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,80 @@ +package com.haejwo.tripcometrue.global.jwt; + +import com.haejwo.tripcometrue.domain.member.repository.MemberRepository; +import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * @author liyusang1 + * @implNote JWT를 이용한 인가 (Authorization) 코드 + */ +@Component +public class JwtAuthorizationFilter extends OncePerRequestFilter { + + private final MemberRepository memberRepository; + private final JwtProvider jwtProvider; + + public JwtAuthorizationFilter( + MemberRepository memberRepository, + JwtProvider jwtProvider + ) { + this.memberRepository = memberRepository; + this.jwtProvider = jwtProvider; + } + + /** + * header가 아닌 cookie에서 토큰을 가져오려고 하는 경우 아래와 같이 바꾸면 된다. + * accessToken = Arrays.stream(request.getCookies()) + * .filter(cookie ->cookie.getName().equals(JwtProperties.COOKIE_NAME)).findFirst().map(Cookie::getValue).orElse(null); + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws IOException, ServletException { + //header에서 가져옴 + List headerValues = Collections.list(request.getHeaders("Authorization")); + String accessToken = headerValues.stream() + .findFirst() + .map(header -> header.replace("Bearer ", "")) + .orElse(null); + + //현재 토큰을 사용 하여 인증을 시도 합니다. + Authentication authentication = getUsernamePasswordAuthenticationToken(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(request, response); + } + + /** + * JWT 토큰으로 User를 찾아서 UsernamePasswordAuthenticationToken를 만들어서 반환한다. + */ + private Authentication getUsernamePasswordAuthenticationToken(String token) { + if (token == null) { + return null; + } + String email = jwtProvider.getEmail(token); + if (email != null) { + return memberRepository.findByMemberBaseEmail(email) + .map(PrincipalDetails::new) + .map(principalDetails -> new UsernamePasswordAuthenticationToken( + principalDetails, // principal + null, // credentials + principalDetails.getAuthorities() + )).orElseThrow(IllegalAccessError::new); + } + return null; // 유저가 없으면 NULL + } +} \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtKey.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtKey.java new file mode 100644 index 00000000..64b7c9b4 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtKey.java @@ -0,0 +1,53 @@ +package com.haejwo.tripcometrue.global.jwt; + +import io.github.cdimascio.dotenv.Dotenv; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Map; +import java.util.Random; +import org.springframework.data.util.Pair; + +/** + * @author liyusang1 + * @implNote JWT Key를 제공하고 조회하는 코드 + */ +public class JwtKey { + + private static Dotenv dotenv = Dotenv.load(); + private static final String JWT_SECRET_KEY1 = dotenv.get("JWT_SECRET_KEY1"); + private static final String JWT_SECRET_KEY2 = dotenv.get("JWT_SECRET_KEY2"); + private static final String JWT_SECRET_KEY3 = dotenv.get("JWT_SECRET_KEY3"); + private static final Map SECRET_KEY_SET = Map.of( + "key1", JWT_SECRET_KEY1, + "key2", JWT_SECRET_KEY2, + "key3", JWT_SECRET_KEY3 + ); + private static final String[] KID_SET = SECRET_KEY_SET.keySet().toArray(new String[0]); + private static Random randomIndex = new Random(); + + /** + * SECRET_KEY_SET 에서 랜덤한 KEY 가져오기 + * + * @return kid와 key Pair + */ + public static Pair getRandomKey() { + String kid = KID_SET[randomIndex.nextInt(KID_SET.length)]; + String secretKey = SECRET_KEY_SET.get(kid); + return Pair.of(kid, Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8))); + } + + /** + * kid로 Key찾기 + * + * @param kid kid + * @return Key + */ + public static Key getKey(String kid) { + String key = SECRET_KEY_SET.getOrDefault(kid, null); + if (key == null) { + return null; + } + return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)); + } +} \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProperties.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProperties.java new file mode 100644 index 00000000..61df9e2b --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProperties.java @@ -0,0 +1,9 @@ +package com.haejwo.tripcometrue.global.jwt; + + +public class JwtProperties { + + public static final int ACCESS_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 40; // 10분 -> 600000 + public static final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 40; + public static final String COOKIE_NAME = "JWT-AUTHENTICATION"; +} \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProvider.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProvider.java new file mode 100644 index 00000000..8ed1c308 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/JwtProvider.java @@ -0,0 +1,69 @@ +package com.haejwo.tripcometrue.global.jwt; + +import com.haejwo.tripcometrue.domain.member.entity.Member; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import java.security.Key; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + /** + * @author liyusang1 + * @implNote 토큰에서 유저 정보를 추출하는 코드 + */ + public String getEmail(String token) { + // jwtToken에서 email을 찾습니다. + return Jwts.parserBuilder() + .setSigningKeyResolver(SigningKeyResolver.instance) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + /** + * member로 토큰 생성 HEADER : alg, kid PAYLOAD : sub, iat, exp SIGNATURE : JwtKey.getRandomKey로 구한 + * Secret Key로 HS512 해시 + * + * @param member 유저 + * @return jwt token + */ + public String createToken(Member member) { + Claims claims = Jwts.claims().setSubject(member.getMemberBase().getEmail()); // subject + Date now = new Date(); // 현재 시간 + Pair key = JwtKey.getRandomKey(); + // JWT Token 생성 + return Jwts.builder() + .setClaims(claims) // 정보 저장 + .setIssuedAt(now) // 토큰 발행 시간 정보 + .setExpiration( + new Date(now.getTime() + JwtProperties.ACCESS_TOKEN_EXPIRATION_TIME)) // 토큰 만료 시간 설정 + .setHeaderParam(JwsHeader.KEY_ID, key.getFirst()) // kid + .signWith(key.getSecond()) // signature + .compact(); + } + + public String createRefreshToken(String email) { + Claims claims = Jwts.claims().setSubject(email); // subject + Date now = new Date(); // 현재 시간 + Pair key = JwtKey.getRandomKey(); + // JWT Token 생성 + String refreshToken = Jwts.builder() + .setClaims(claims) // 정보 저장 + .setIssuedAt(now) // 토큰 발행 시간 정보 + .setExpiration(new Date( + now.getTime() + JwtProperties.REFRESH_TOKEN_EXPIRATION_TIME)) // 토큰 만료 시간 설정 + .setHeaderParam(JwsHeader.KEY_ID, key.getFirst()) // kid + .signWith(key.getSecond()) // signature + .compact(); + + return refreshToken; + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/global/jwt/SigningKeyResolver.java b/src/main/java/com/haejwo/tripcometrue/global/jwt/SigningKeyResolver.java new file mode 100644 index 00000000..16a895d5 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/jwt/SigningKeyResolver.java @@ -0,0 +1,24 @@ +package com.haejwo.tripcometrue.global.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.SigningKeyResolverAdapter; +import java.security.Key; + +/** + * @author liyusang1 + * @implNote JwsHeader를 통해 Signature 검증에 필요한 Key를 가져오는 코드 + */ +public class SigningKeyResolver extends SigningKeyResolverAdapter { + + public static SigningKeyResolver instance = new SigningKeyResolver(); + + @Override + public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) { + String kid = jwsHeader.getKeyId(); + if (kid == null) { + return null; + } + return JwtKey.getKey(kid); + } +} \ No newline at end of file diff --git a/src/main/java/com/haejwo/tripcometrue/global/springsecurity/ApplicationAuditAware.java b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/ApplicationAuditAware.java new file mode 100644 index 00000000..6c345c4b --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/ApplicationAuditAware.java @@ -0,0 +1,28 @@ +package com.haejwo.tripcometrue.global.springsecurity; + +import com.haejwo.tripcometrue.domain.member.entity.Member; +import java.util.Optional; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class ApplicationAuditAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + Authentication authentication = + SecurityContextHolder + .getContext() + .getAuthentication(); + if (authentication == null || + !authentication.isAuthenticated() || + authentication instanceof AnonymousAuthenticationToken + ) { + return Optional.empty(); + } + + Member memberPrincipal = ((PrincipalDetails) authentication.getPrincipal()).getMember(); + return Optional.ofNullable(memberPrincipal.getId()); + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/global/springsecurity/AuthConfig.java b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/AuthConfig.java new file mode 100644 index 00000000..7bc32a26 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/AuthConfig.java @@ -0,0 +1,61 @@ +package com.haejwo.tripcometrue.global.springsecurity; + +import com.haejwo.tripcometrue.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +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.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class AuthConfig { + + private final MemberRepository memberRepository; + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public UserDetailsService userDetailsService() { + return this::loadUserByUsername; + } + + @Bean + public AuditorAware auditorAware() { + return new ApplicationAuditAware(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } + + // AppConfig에서 정의한 PasswordEncoder 빈을 참조 + @Autowired + private PasswordEncoder passwordEncoder; + + @Bean + public PasswordEncoder passwordEncoder() { + return passwordEncoder; + } + + private PrincipalDetails loadUserByUsername(String email) { + return memberRepository.findByMemberBaseEmail(email) + .map(PrincipalDetails::new) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/global/springsecurity/PrincipalDetails.java b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/PrincipalDetails.java new file mode 100644 index 00000000..0e3693f2 --- /dev/null +++ b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/PrincipalDetails.java @@ -0,0 +1,89 @@ +package com.haejwo.tripcometrue.global.springsecurity; + +import com.haejwo.tripcometrue.domain.member.entity.Member; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +@AllArgsConstructor +@RequiredArgsConstructor +public class PrincipalDetails implements UserDetails, OAuth2User { + + @Getter + private Member member; + private String username; + private String password; + private Map attributes; + + //일반 로그인 + public PrincipalDetails(Member member) { + this.member = member; + } + + //OAuth 로그인 + public PrincipalDetails(Member member, Map attributes) { + this.member = member; + } + + @Override + public A getAttribute(String name) { + return OAuth2User.super.getAttribute(name); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getMemberBase().getAuthority())); + } + + @Override + public String getPassword() { + return member.getMemberBase().getPassword(); + } + + @Override + public String getUsername() { + return member.getMemberBase().getNickname(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public String getEmail() { + return member.getMemberBase().getEmail(); + } + + @Override + public String getName() { + return null; + } +} diff --git a/src/main/java/com/haejwo/tripcometrue/global/springsecurity/SpringSecurityConfig.java b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/SpringSecurityConfig.java index c9e992d5..c97d87bd 100644 --- a/src/main/java/com/haejwo/tripcometrue/global/springsecurity/SpringSecurityConfig.java +++ b/src/main/java/com/haejwo/tripcometrue/global/springsecurity/SpringSecurityConfig.java @@ -1,16 +1,19 @@ package com.haejwo.tripcometrue.global.springsecurity; +import com.haejwo.tripcometrue.global.jwt.JwtAuthenticationFilter; +import com.haejwo.tripcometrue.global.jwt.JwtAuthorizationFilter; import com.haejwo.tripcometrue.global.util.CustomResponseUtil; import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @@ -19,6 +22,9 @@ @RequiredArgsConstructor public class SpringSecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthorizationFilter jwtAuthorizationFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { @@ -48,14 +54,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, http.sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + // jwt filter + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class); + http.authorizeHttpRequests(authz -> authz /* .requestMatchers(new AntPathRequestMatcher("ant matcher")).authenticated() .requestMatchers(new AntPathRequestMatcher("role sample")).hasRole("ADMIN") - .requestMatchers(new AntPathRequestMatcher("role sample", HttpMethod.POST.name())).hasRole("ADMIN") - .requestMatchers(HttpMethod.OPTIONS, "/basket/**").permitAll() // OPTIONS 메서드에 대한 권한 허용 */ + .requestMatchers(HttpMethod.OPTIONS, "/basket/**").permitAll() // OPTIONS 메서드에 대한 권한 허용 + .requestMatchers(new AntPathRequestMatcher("role sample", HttpMethod.POST.name())).hasRole("ADMIN") */ .requestMatchers(new AntPathRequestMatcher("/login/**")).permitAll() - .requestMatchers(new AntPathRequestMatcher("/v1/member/signup/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/v1/member/signup")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/v1/member/test/jwt")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/v1/member/check-duplicated-email")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/v1/places/**")).permitAll() .anyRequest().authenticated()); diff --git a/src/test/http/member/login.http b/src/test/http/member/login.http new file mode 100644 index 00000000..6cdddb5f --- /dev/null +++ b/src/test/http/member/login.http @@ -0,0 +1,8 @@ +### 로그인 +POST http://localhost:8080/login +Content-Type: application/json + +{ + "email": "liyusang1@naver.com", + "password": "123456" +} \ No newline at end of file diff --git a/src/test/http/member/signup.http b/src/test/http/member/signup.http index 6ed0b546..a246c257 100644 --- a/src/test/http/member/signup.http +++ b/src/test/http/member/signup.http @@ -3,7 +3,11 @@ POST http://localhost:8080/v1/member/signup Content-Type: application/json { - "email": "test11@naver.com", + "email": "test1@naver.com", "password": "123456", "nickname": "testusername" -} \ No newline at end of file +} + +### 이메일 중복 체크 +GET http://localhost:8080/v1/member/check-duplicated-email?email=test1@naver.com +Content-Type: application/json \ No newline at end of file diff --git a/src/test/http/member/testjwt.http b/src/test/http/member/testjwt.http new file mode 100644 index 00000000..d1f6ab70 --- /dev/null +++ b/src/test/http/member/testjwt.http @@ -0,0 +1,3 @@ +### JWT 토큰 테스트 +GET http://localhost:8080/v1/member/test/jwt +Authorization: eyJraWQiOiJrZXkyIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJsaXl1c2FuZzFAbmF2ZXIuY29tIiwiaWF0IjoxNzA0MzkxMTE3LCJleHAiOjE3MDQ1MzUxMTd9.jZrLbQjtoUnHOAq7W4IsMUR2xdBn_9DjjBf2Z_rCxWs \ No newline at end of file