diff --git a/build.gradle b/build.gradle index a6e3c00..a3a3e2b 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,6 @@ dependencies { implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.mysql:mysql-connector-java' runtimeOnly 'com.h2database:h2' //mail @@ -61,6 +60,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' } + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/store/itpick/backend/common/interceptor/JwtAuthInterceptor.java b/src/main/java/store/itpick/backend/common/interceptor/JwtAuthInterceptor.java index aee2ee5..fea855b 100644 --- a/src/main/java/store/itpick/backend/common/interceptor/JwtAuthInterceptor.java +++ b/src/main/java/store/itpick/backend/common/interceptor/JwtAuthInterceptor.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import store.itpick.backend.service.UserService; import static store.itpick.backend.common.response.status.BaseExceptionResponseStatus.*; @@ -22,8 +23,8 @@ public class JwtAuthInterceptor implements HandlerInterceptor { private static final String JWT_TOKEN_PREFIX = "Bearer "; - private final JwtProvider jwtProvider; + private final UserService userService; // 컨트롤러 호출전에 JWT 검증 @Override @@ -35,8 +36,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons String email = jwtProvider.getPrincipal(accessToken); validatePayload(email); - // long userId = authService.getUserIdByEmail(email); - // request.setAttribute("userId", userId); + long userId = userService.getUserIdByEmail(email); + request.setAttribute("userId", userId); return true; } diff --git a/src/main/java/store/itpick/backend/common/interceptor/JwtAuthRefreshInterceptor.java b/src/main/java/store/itpick/backend/common/interceptor/JwtAuthRefreshInterceptor.java new file mode 100644 index 0000000..8be33dc --- /dev/null +++ b/src/main/java/store/itpick/backend/common/interceptor/JwtAuthRefreshInterceptor.java @@ -0,0 +1,71 @@ +package store.itpick.backend.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import store.itpick.backend.common.exception.jwt.bad_request.JwtNoTokenException; +import store.itpick.backend.common.exception.jwt.bad_request.JwtUnsupportedTokenException; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtExpiredTokenException; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtInvalidTokenException; +import store.itpick.backend.jwt.JwtProvider; +import store.itpick.backend.service.UserService; + +import static store.itpick.backend.common.response.status.BaseExceptionResponseStatus.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthRefreshInterceptor implements HandlerInterceptor { + + private static final String JWT_TOKEN_PREFIX = "Bearer "; + private final JwtProvider jwtProvider; + private final UserService userService; + + // 컨트롤러 호출전에 JWT 검증 + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + + String refreshToken = resolveRefreshToken(request); + validateRefreshToken(refreshToken); + + String email = jwtProvider.getPrincipal(refreshToken); + validatePayload(email); + + long userId = userService.getUserIdByEmail(email); + request.setAttribute("userId", userId); + return true; + + } + + private String resolveRefreshToken(HttpServletRequest request) { + String token = request.getHeader(HttpHeaders.AUTHORIZATION); + validateToken(token); + return token.substring(JWT_TOKEN_PREFIX.length()); + } + + private void validateToken(String token) { + if (token == null) { + throw new JwtNoTokenException(TOKEN_NOT_FOUND); + } + if (!token.startsWith(JWT_TOKEN_PREFIX)) { + throw new JwtUnsupportedTokenException(UNSUPPORTED_TOKEN_TYPE); + } + } + + private void validateRefreshToken(String accessToken) { + if (jwtProvider.isExpiredToken(accessToken)) { + throw new JwtExpiredTokenException(EXPIRED_TOKEN); + } + } + + private void validatePayload(String email) { + if (email == null) { + throw new JwtInvalidTokenException(INVALID_TOKEN); + } + } + +} \ No newline at end of file diff --git a/src/main/java/store/itpick/backend/common/response/status/BaseExceptionResponseStatus.java b/src/main/java/store/itpick/backend/common/response/status/BaseExceptionResponseStatus.java index c088d91..058b199 100644 --- a/src/main/java/store/itpick/backend/common/response/status/BaseExceptionResponseStatus.java +++ b/src/main/java/store/itpick/backend/common/response/status/BaseExceptionResponseStatus.java @@ -34,12 +34,14 @@ public enum BaseExceptionResponseStatus implements ResponseStatus { INVALID_TOKEN(4003, HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), MALFORMED_TOKEN(4004, HttpStatus.UNAUTHORIZED.value(), "토큰이 올바르게 구성되지 않았습니다."), EXPIRED_TOKEN(4005, HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다."), - TOKEN_MISMATCH(4006, HttpStatus.UNAUTHORIZED.value(), "로그인 정보가 토큰 정보와 일치하지 않습니다."), + TOKEN_MISMATCH(4006, HttpStatus.UNAUTHORIZED.value(), "회원 정보가 토큰 정보와 일치하지 않습니다."), + EXPIRED_REFRESH_TOKEN(4007, HttpStatus.UNAUTHORIZED.value(), "다시 로그인 해주세요."), + /** * 5000: User 오류 */ - INVALID_USER_VALUE(5000, HttpStatus.BAD_REQUEST.value(), "회원가입 요청에서 잘못된 값이 존재합니다."), + INVALID_USER_VALUE(5000, HttpStatus.BAD_REQUEST.value(), "회원가입/로그인 요청에서 잘못된 값이 존재합니다."), DUPLICATE_EMAIL(5001, HttpStatus.BAD_REQUEST.value(), "이미 존재하는 이메일입니다."), DUPLICATE_NICKNAME(5002, HttpStatus.BAD_REQUEST.value(), "이미 존재하는 닉네임입니다."), USER_NOT_FOUND(5003, HttpStatus.BAD_REQUEST.value(), "존재하지 않는 회원입니다."), @@ -47,7 +49,8 @@ public enum BaseExceptionResponseStatus implements ResponseStatus { INVALID_USER_STATUS(5005, HttpStatus.BAD_REQUEST.value(), "잘못된 회원 status 값입니다."), EMAIL_NOT_FOUND(5006, HttpStatus.BAD_REQUEST.value(), "존재하지 않는 이메일입니다."), INVALID_PASSWORD(5007, HttpStatus.BAD_REQUEST.value(), "유효하지 않는 password입니다."), - UNABLE_TO_SEND_EMAIL(5008,HttpStatus.BAD_REQUEST.value(),"메일을 전송할 수 없습니다."), + INVALID_REFRESHTOKEN(5008, HttpStatus.BAD_REQUEST.value(), "유효하지 않는 토큰입니다."), + UNABLE_TO_SEND_EMAIL(5012,HttpStatus.BAD_REQUEST.value(),"메일을 전송할 수 없습니다."), NO_SUCH_ALGORITHM(5009, HttpStatus.BAD_REQUEST.value(), "인증 번호 생성을 위한 알고리즘을 찾을 수 없습니다."), AUTH_CODE_IS_NOT_SAME(5010, HttpStatus.BAD_REQUEST.value(), "인증 번호가 일치하지 않습니다."), MEMBER_EXISTS(5011,HttpStatus.BAD_REQUEST.value(), "이미 존재하는 회원입니다."); diff --git a/src/main/java/store/itpick/backend/config/WebConfig.java b/src/main/java/store/itpick/backend/config/WebConfig.java index bfe29f6..cf56ebc 100644 --- a/src/main/java/store/itpick/backend/config/WebConfig.java +++ b/src/main/java/store/itpick/backend/config/WebConfig.java @@ -21,8 +21,8 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtAuthenticationInterceptor) .order(1) - .addPathPatterns("/auth/test","/users/**") - .excludePathPatterns("/users"); + .addPathPatterns("/**") + .excludePathPatterns("/auth/login", "/auth/singup", "/auth/refresh"); //인터셉터 적용 범위 수정 } diff --git a/src/main/java/store/itpick/backend/controller/UserController.java b/src/main/java/store/itpick/backend/controller/UserController.java index 47ad0c2..5ed0bd7 100644 --- a/src/main/java/store/itpick/backend/controller/UserController.java +++ b/src/main/java/store/itpick/backend/controller/UserController.java @@ -5,12 +5,22 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import store.itpick.backend.common.argument_resolver.PreAuthorize; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtExpiredTokenException; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtInvalidTokenException; import store.itpick.backend.common.response.BaseResponse; import store.itpick.backend.dto.auth.LoginRequest; import store.itpick.backend.dto.auth.LoginResponse; +import store.itpick.backend.dto.auth.RefreshRequest; +import store.itpick.backend.dto.auth.RefreshResponse; import store.itpick.backend.dto.user.user.PostUserRequest; import store.itpick.backend.dto.user.user.PostUserResponse; import store.itpick.backend.service.UserService; @@ -22,11 +32,18 @@ @Slf4j @RestController @RequiredArgsConstructor +@RequestMapping("/auth") public class UserController { @Autowired private final UserService userService; + @PostMapping("/refresh") + public BaseResponse refresh(@Validated @RequestBody RefreshRequest refreshRequest) { + return new BaseResponse<>(userService.refresh(refreshRequest.getRefreshToken())); + } + + /** * 로그인 */ @@ -39,8 +56,12 @@ public BaseResponse login(@Validated @RequestBody LoginRequest au return new BaseResponse<>(userService.login(authRequest)); } - @PostMapping("/logout") - public BaseResponse logoutUser() { + /** + * 로그아웃 : db의 refresh 토큰을 null로 설정 + */ + @PatchMapping("/logout") + public BaseResponse logout(@PreAuthorize long userId) { + userService.logout(userId); return new BaseResponse<>(null); } diff --git a/src/main/java/store/itpick/backend/dto/auth/JwtDTO.java b/src/main/java/store/itpick/backend/dto/auth/JwtDTO.java new file mode 100644 index 0000000..cdbb129 --- /dev/null +++ b/src/main/java/store/itpick/backend/dto/auth/JwtDTO.java @@ -0,0 +1,16 @@ +package store.itpick.backend.dto.auth; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class JwtDTO { + private String accessToken; + private String refreshToken; + + public JwtDTO(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} \ No newline at end of file diff --git a/src/main/java/store/itpick/backend/dto/auth/LoginResponse.java b/src/main/java/store/itpick/backend/dto/auth/LoginResponse.java index 8f262d3..04f5d57 100644 --- a/src/main/java/store/itpick/backend/dto/auth/LoginResponse.java +++ b/src/main/java/store/itpick/backend/dto/auth/LoginResponse.java @@ -8,6 +8,6 @@ public class LoginResponse { private long userId; - private String jwt; + private JwtDTO jwt; } diff --git a/src/main/java/store/itpick/backend/dto/auth/LogoutRequest.java b/src/main/java/store/itpick/backend/dto/auth/LogoutRequest.java new file mode 100644 index 0000000..07793a3 --- /dev/null +++ b/src/main/java/store/itpick/backend/dto/auth/LogoutRequest.java @@ -0,0 +1,15 @@ +package store.itpick.backend.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class LogoutRequest { + + @NotBlank(message = "refreshToken: {NotBlank}") + private String refreshToken; +} diff --git a/src/main/java/store/itpick/backend/dto/auth/RefreshRequest.java b/src/main/java/store/itpick/backend/dto/auth/RefreshRequest.java new file mode 100644 index 0000000..ad17b9d --- /dev/null +++ b/src/main/java/store/itpick/backend/dto/auth/RefreshRequest.java @@ -0,0 +1,15 @@ +package store.itpick.backend.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class RefreshRequest { + + @NotBlank(message = "refreshToken: {NotBlank}") + private String refreshToken; +} diff --git a/src/main/java/store/itpick/backend/dto/auth/RefreshResponse.java b/src/main/java/store/itpick/backend/dto/auth/RefreshResponse.java new file mode 100644 index 0000000..11024de --- /dev/null +++ b/src/main/java/store/itpick/backend/dto/auth/RefreshResponse.java @@ -0,0 +1,8 @@ +package store.itpick.backend.dto.auth; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class RefreshResponse { + private String accessToken; +} diff --git a/src/main/java/store/itpick/backend/jwt/JwtProvider.java b/src/main/java/store/itpick/backend/jwt/JwtProvider.java index 4ca5da6..2a94981 100644 --- a/src/main/java/store/itpick/backend/jwt/JwtProvider.java +++ b/src/main/java/store/itpick/backend/jwt/JwtProvider.java @@ -22,6 +22,9 @@ public class JwtProvider { @Value("${secret.jwt-expired-in}") private long JWT_EXPIRED_IN; + @Value("${secret.jwt-refresh-expired-in}") + private long JWT_REFRESH_EXPIRED_IN; + public String createToken(String principal, long userId) { log.info("JWT key={}", JWT_SECRET_KEY); @@ -38,16 +41,31 @@ public String createToken(String principal, long userId) { .compact(); } + public String createRefreshToken(String principal, long userId) { + log.info("JWT key={}", JWT_SECRET_KEY); + + Claims claims = Jwts.claims().setSubject(principal); + Date now = new Date(); + Date validity = new Date(now.getTime() + JWT_REFRESH_EXPIRED_IN); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .claim("userId", userId) + .signWith(SignatureAlgorithm.HS256, JWT_SECRET_KEY) + .compact(); + } + public boolean isExpiredToken(String token) throws JwtInvalidTokenException { try { Jws claims = Jwts.parserBuilder() .setSigningKey(JWT_SECRET_KEY).build() - .parseClaimsJws(token); + .parseClaimsJws(token); // 유효성 확인 return claims.getBody().getExpiration().before(new Date()); } catch (ExpiredJwtException e) { return true; - } catch (UnsupportedJwtException e) { throw new JwtUnsupportedTokenException(UNSUPPORTED_TOKEN_TYPE); } catch (MalformedJwtException e) { diff --git a/src/main/java/store/itpick/backend/service/UserService.java b/src/main/java/store/itpick/backend/service/UserService.java index 4b093b7..dbece12 100644 --- a/src/main/java/store/itpick/backend/service/UserService.java +++ b/src/main/java/store/itpick/backend/service/UserService.java @@ -1,16 +1,22 @@ package store.itpick.backend.service; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import store.itpick.backend.common.exception.UserException; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtExpiredTokenException; +import store.itpick.backend.common.exception.jwt.unauthorized.JwtInvalidTokenException; import store.itpick.backend.common.response.status.BaseExceptionResponseStatus; +import store.itpick.backend.dto.auth.JwtDTO; import store.itpick.backend.dto.auth.LoginRequest; import store.itpick.backend.dto.auth.LoginResponse; +import store.itpick.backend.dto.auth.RefreshResponse; import store.itpick.backend.dto.user.user.PostUserRequest; import store.itpick.backend.dto.user.user.PostUserResponse; import store.itpick.backend.jwt.JwtProvider; @@ -24,8 +30,11 @@ import java.util.List; import java.util.Optional; import java.util.Random; +import java.util.concurrent.TimeUnit; import static store.itpick.backend.common.response.status.BaseExceptionResponseStatus.*; +import static store.itpick.backend.common.response.status.BaseExceptionResponseStatus.*; +import static store.itpick.backend.common.response.status.BaseExceptionResponseStatus.INVALID_TOKEN; @Slf4j @@ -43,26 +52,35 @@ public class UserService { private long authCodeExpirationMillis; private static final String AUTH_CODE_PREFIX = "AuthCode "; + private final JwtProvider jwtProvider; + + + public LoginResponse login(LoginRequest authRequest) { String email = authRequest.getEmail(); // TODO: 1. 이메일 유효성 확인 - long userId; + User user; try { - userId = userRepository.getUserByEmail(email).get().getUserId(); + user = userRepository.getUserByEmail(email).get(); } catch (IncorrectResultSizeDataAccessException e) { throw new UserException(EMAIL_NOT_FOUND); } + long userId = user.getUserId(); // TODO: 2. 비밀번호 일치 확인 validatePassword(authRequest.getPassword(), userId); // TODO: 3. JWT 갱신 - String updatedJwt = jwtTokenProvider.createToken(email, userId); + String updatedAccessToken = jwtProvider.createToken(email, userId); + String updatedRefreshToken = jwtProvider.createRefreshToken(email, userId); + user.setRefreshToken(updatedRefreshToken); + userRepository.save(user); + JwtDTO jwtDTO = new JwtDTO(updatedAccessToken, updatedRefreshToken); - return new LoginResponse(userId, updatedJwt); + return new LoginResponse(userId, jwtDTO); } private void validatePassword(String password, long userId) { @@ -93,16 +111,41 @@ public PostUserResponse signUp(PostUserRequest postUserRequest) { return new PostUserResponse(user.getUserId()); } - private void validateEmail(String email) { - if (userRepository.existsByEmailAndStatusIn(email, List.of("active", "dormant"))) { - throw new UserException(DUPLICATE_EMAIL); + + public RefreshResponse refresh(String refreshToken){ + // 만료 & 유효성 확인, 로그아웃 확인 + if(jwtProvider.isExpiredToken(refreshToken) || refreshToken == null || refreshToken.isEmpty()){ + throw new JwtExpiredTokenException(EXPIRED_REFRESH_TOKEN); + } + String email = jwtProvider.getPrincipal(refreshToken); + if (email == null) { + throw new JwtInvalidTokenException(INVALID_TOKEN); + } + + // 이메일 유효성 확인 + User user; + try { + user = userRepository.getUserByEmail(email).get(); + } catch (IncorrectResultSizeDataAccessException e) { + throw new UserException(EMAIL_NOT_FOUND); } + long userId = user.getUserId(); + + // 엑세스 토큰 재발급 + return new RefreshResponse(jwtProvider.createToken(email, userId)); } - private void validateNickname(String nickname) { - if (userRepository.existsByNicknameAndStatusIn(nickname, List.of("active", "dormant"))) { - throw new UserException(DUPLICATE_NICKNAME); + // 로그아웃 + public void logout(long userId) { + + User user; + try { + user = userRepository.getUserByUserId(userId).get(); + } catch (IncorrectResultSizeDataAccessException e) { + throw new UserException(USER_NOT_FOUND); } + user.setRefreshToken(null); + userRepository.save(user); } public void modifyUserStatus_deleted(String token) { @@ -163,4 +206,27 @@ public void verifiedCode(String email, String authCode) { } } + + + + + + + private void validateEmail(String email) { + if (userRepository.existsByEmailAndStatusIn(email, List.of("active", "dormant"))) { + throw new UserException(BaseExceptionResponseStatus.DUPLICATE_EMAIL); + } + } + + private void validateNickname(String nickname) { + if (userRepository.existsByNicknameAndStatusIn(nickname, List.of("active", "dormant"))) { + throw new UserException(BaseExceptionResponseStatus.DUPLICATE_NICKNAME); + } + } + + public long getUserIdByEmail(String email) { + return userRepository.getUserByEmail(email).get().getUserId(); + } + + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf7d082..98a5adb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,11 +18,11 @@ spring: password: ${DATASOURCE_PASSWORD} # url: jdbc:h2:tcp://localhost/~/itpick # username: sa -# password: +## password: driver-class-name: com.mysql.cj.jdbc.Driver dbcp2: validation-query: select 1 - hikari: +# hikari: # driver-class-name: org.h2.Driver sql: init: @@ -57,6 +57,7 @@ spring: redis: host: localhost port: 6379 + --- spring: @@ -106,6 +107,10 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect + h2: + console: + enabled: true + --- @@ -137,6 +142,7 @@ spring: secret: jwt-secret-key: ${JWT_SECRET_KEY} jwt-expired-in: ${JWT_EXPIRED_IN} + jwt-refresh-expired-in: ${JWT_REFRESH_EXPIRED_IN} ---