diff --git a/src/main/java/com/tasksprints/auction/api/auth/AuthController.java b/src/main/java/com/tasksprints/auction/api/auth/AuthController.java new file mode 100644 index 00000000..b2714d2b --- /dev/null +++ b/src/main/java/com/tasksprints/auction/api/auth/AuthController.java @@ -0,0 +1,44 @@ +package com.tasksprints.auction.api.auth; + +import static org.springframework.http.HttpHeaders.SET_COOKIE; + +import com.tasksprints.auction.common.constant.ApiResponseMessages; +import com.tasksprints.auction.common.response.ApiResult; +import com.tasksprints.auction.domain.auth.dto.request.LoginRequest; +import com.tasksprints.auction.domain.auth.dto.response.AccessToken; +import com.tasksprints.auction.domain.auth.dto.response.ResponseTokens; +import com.tasksprints.auction.domain.auth.service.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CookieValue; +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; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody LoginRequest.Login login) { + Long userId = authService.validateLogin(login.email(), login.password()); + ResponseTokens responseTokens = authService.issueResponseTokens(userId); + + return ResponseEntity.ok() + .header(SET_COOKIE, responseTokens.refreshToken().toString()) + .body(ApiResult.success(ApiResponseMessages.LOGIN_SUCCESS, responseTokens.accessToken())); + } + + @GetMapping("/reissue") + public ResponseEntity> reissueTokens(@CookieValue("refresh-token") String refreshToken) { + ResponseTokens responseTokens = authService.reissueResponseTokens(refreshToken); + + return ResponseEntity.ok() + .header(SET_COOKIE, responseTokens.refreshToken().toString()) + .body(ApiResult.success(ApiResponseMessages.LOGIN_SUCCESS, responseTokens.accessToken())); + } +} diff --git a/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java b/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java new file mode 100644 index 00000000..f272e578 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/config/JwtConfig.java @@ -0,0 +1,24 @@ +package com.tasksprints.auction.common.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Getter +@RequiredArgsConstructor +public class JwtConfig { + + @Value("${jwt.expire-ms}") + private final Long accessExpireMs; + + @Value("${jwt.expire-ms}") + private final Long refreshExpireMs; + + @Value("${jwt.issuer}") + private final String issuer; + + @Value("${jwt.secret}") + private final String secretKey; +} diff --git a/src/main/java/com/tasksprints/auction/common/config/WebConfig.java b/src/main/java/com/tasksprints/auction/common/config/WebConfig.java index 0f7bb22a..b895df7c 100644 --- a/src/main/java/com/tasksprints/auction/common/config/WebConfig.java +++ b/src/main/java/com/tasksprints/auction/common/config/WebConfig.java @@ -1,7 +1,7 @@ package com.tasksprints.auction.common.config; +import com.tasksprints.auction.common.resolver.AuthenticationResolver; import com.tasksprints.auction.common.resolver.SearchConditionResolver; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; @@ -12,14 +12,17 @@ @Configuration public class WebConfig implements WebMvcConfigurer { private final SearchConditionResolver searchConditionResolver; + private final AuthenticationResolver authenticationResolver; - public WebConfig(SearchConditionResolver searchConditionResolver) { + public WebConfig(SearchConditionResolver searchConditionResolver, AuthenticationResolver authenticationResolver) { this.searchConditionResolver = searchConditionResolver; + this.authenticationResolver = authenticationResolver; } @Override public void addArgumentResolvers(List resolvers) { resolvers.add(searchConditionResolver); + resolvers.add(authenticationResolver); } @Override diff --git a/src/main/java/com/tasksprints/auction/common/constant/ApiResponseMessages.java b/src/main/java/com/tasksprints/auction/common/constant/ApiResponseMessages.java index 9804ce3d..00aa3e75 100644 --- a/src/main/java/com/tasksprints/auction/common/constant/ApiResponseMessages.java +++ b/src/main/java/com/tasksprints/auction/common/constant/ApiResponseMessages.java @@ -30,6 +30,11 @@ public class ApiResponseMessages { public static final String REVIEWS_RETRIEVED = "Reviews successfully retrieved"; public static final String REVIEW_RETRIEVED = "Review successfully retrieved"; + // AUTH + public static final String ACCESS_TOKEN_NOT_FOUND = "Access token Not found"; + public static final String REFRESH_TOKEN_NOT_FOUND = "Refresh token not found"; + public static final String LOGIN_SUCCESS = "Login Success"; + // Additional messages can be defined as needed // PAYMENT diff --git a/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java b/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java index 670739c3..64cd9ce4 100644 --- a/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/tasksprints/auction/common/handler/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import com.tasksprints.auction.domain.auction.exception.AuctionEndedException; import com.tasksprints.auction.domain.auction.exception.AuctionNotFoundException; import com.tasksprints.auction.domain.auction.exception.InvalidAuctionTimeException; +import com.tasksprints.auction.domain.auth.exception.AuthException; import com.tasksprints.auction.domain.bid.exception.BidNotFoundException; import com.tasksprints.auction.domain.bid.exception.InvalidBidAmountException; import com.tasksprints.auction.domain.payment.exception.InvalidSessionException; @@ -89,4 +90,9 @@ public ResponseEntity> handleIllegalStateException(IllegalStat public ResponseEntity> handleRuntimeException(RuntimeException ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResult.failure(ex.getMessage())); } + + @ExceptionHandler(AuthException.class) + public ResponseEntity> handleAuthException(AuthException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResult.failure(ApiResponseMessages.USER_NOT_FOUND)); + } } diff --git a/src/main/java/com/tasksprints/auction/common/jwt/Auth.java b/src/main/java/com/tasksprints/auction/common/jwt/Auth.java new file mode 100644 index 00000000..2061c8ab --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/Auth.java @@ -0,0 +1,11 @@ +package com.tasksprints.auction.common.jwt; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java b/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java deleted file mode 100644 index e87ce1d1..00000000 --- a/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.tasksprints.auction.common.jwt; - -import lombok.Getter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Component -@Getter -public class JwtProperties { - @Value("${jwt.header}") - private String header; - - @Value("${jwt.prefix}") - private String prefix; - - @Value("${jwt.expire-ms}") - private Long expireMs; - - @Value("${jwt.expire-ms}") - private Long refreshExpireMs; - - @Value("${jwt.issuer}") - private String issuer; - - @Value("${jwt.secret}") - private String secretKey; -} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java b/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java index 033e8ead..aaf55948 100644 --- a/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java +++ b/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java @@ -2,8 +2,12 @@ import static com.tasksprints.auction.common.util.TimeUtil.*; -import com.tasksprints.auction.common.jwt.dto.response.JwtResponse; +import com.tasksprints.auction.common.config.JwtConfig; +import com.tasksprints.auction.domain.auth.dto.response.AccessToken; +import com.tasksprints.auction.domain.auth.dto.response.UserTokens; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.time.Clock; @@ -16,44 +20,51 @@ @Component @RequiredArgsConstructor public class JwtProvider { - - private final JwtProperties jwtProperties; + private static final String EMPTY_SUBJECT = ""; + private final JwtConfig jwtConfig; private final Clock clock; - public JwtResponse generateToken(Long userId, String userRole) { - return JwtResponse.of(createAccessToken(userId, userRole), createRefreshToken()); - } + public UserTokens generateToken(String subject) { + String accessTokenValue = createToken(subject, jwtConfig.getAccessExpireMs()); + AccessToken accessToken = AccessToken.of(accessTokenValue); - public String createAccessToken(Long userId, String userRole) { + String refreshToken = createToken(EMPTY_SUBJECT, jwtConfig.getRefreshExpireMs()); - Date now = localDateTimeToDate(LocalDateTime.now(clock)); - - return Jwts.builder().setIssuer(jwtProperties.getIssuer()).claim("userId", userId).claim("userRole", userRole) - .setIssuedAt(now).setExpiration(new Date(now.getTime() + jwtProperties.getExpireMs())) - .signWith(SignatureAlgorithm.HS256, JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())).compact(); + return UserTokens.of( + accessToken, + refreshToken + ); } - public String createRefreshToken() { - + private String createToken(String subject, Long expiredMs) { + byte[] secretKey = JwtUtil.encodeSecretKey(jwtConfig.getSecretKey()); Date now = localDateTimeToDate(LocalDateTime.now(clock)); - - return Jwts.builder().setIssuer(jwtProperties.getIssuer()).setIssuedAt(now) - .setExpiration(new Date(now.getTime() + jwtProperties.getRefreshExpireMs())) - .signWith(SignatureAlgorithm.HS256, JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())).compact(); + Date expirationTime = new Date(now.getTime() + expiredMs); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer(jwtConfig.getIssuer()) + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expirationTime) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); } - public boolean verifyToken(String token) { - - Date now = localDateTimeToDate(LocalDateTime.now(clock)); - - Claims claims = getClaims(token); + public void validateToken(String token) { + parseToken(token); + } - return !claims.getExpiration().before(now); + public String getSubject(String token) { + return parseToken(token) + .getBody() + .getSubject(); } - public Claims getClaims(String token) { - return Jwts.parser().setSigningKey(JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())) - .parseClaimsJws(token) - .getBody(); + private Jws parseToken(String token) { + byte[] secretKey = JwtUtil.encodeSecretKey(jwtConfig.getSecretKey()); + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token); } } diff --git a/src/main/java/com/tasksprints/auction/common/jwt/UserCheck.java b/src/main/java/com/tasksprints/auction/common/jwt/UserCheck.java new file mode 100644 index 00000000..1e8e8c01 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/UserCheck.java @@ -0,0 +1,25 @@ +package com.tasksprints.auction.common.jwt; + +import com.tasksprints.auction.domain.auth.exception.AuthException; +import com.tasksprints.auction.domain.auth.model.Accessor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class UserCheck { + @Before("@annotation(UserOnly)") + public void userCheck(JoinPoint joinPoint) { + Object[] args = joinPoint.getArgs(); + + for (Object arg : args) { + if (arg instanceof Accessor accessor) { + if (!accessor.isUser()) { + throw new AuthException("Invalid Access"); + } + } + } + } +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/UserOnly.java b/src/main/java/com/tasksprints/auction/common/jwt/UserOnly.java new file mode 100644 index 00000000..bc794653 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/UserOnly.java @@ -0,0 +1,11 @@ +package com.tasksprints.auction.common.jwt; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserOnly { +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java b/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java deleted file mode 100644 index 1d209eca..00000000 --- a/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.tasksprints.auction.common.jwt.dto.response; - -import lombok.*; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class JwtResponse { - private String refreshToken; - private String accessToken; - - public static JwtResponse of(String accessToken, String refreshToken) { - return JwtResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - } -} diff --git a/src/main/java/com/tasksprints/auction/common/resolver/AuthenticationResolver.java b/src/main/java/com/tasksprints/auction/common/resolver/AuthenticationResolver.java new file mode 100644 index 00000000..6715a407 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/resolver/AuthenticationResolver.java @@ -0,0 +1,54 @@ +package com.tasksprints.auction.common.resolver; + +import com.tasksprints.auction.common.jwt.Auth; +import com.tasksprints.auction.common.jwt.JwtProvider; +import com.tasksprints.auction.domain.auth.TokenExtractor; +import com.tasksprints.auction.domain.auth.exception.RefreshTokenException; +import com.tasksprints.auction.domain.auth.model.Accessor; +import com.tasksprints.auction.domain.auth.service.RefreshTokenCookieManager; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthenticationResolver implements HandlerMethodArgumentResolver { + private final JwtProvider jwtProvider; + private final RefreshTokenCookieManager refreshTokenCookieManager; + private final TokenExtractor accessTokenExtractor; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter + .hasParameterAnnotation(Auth.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + if (request == null) { + throw new IllegalArgumentException(); + } + try { + String refreshToken = refreshTokenCookieManager.extractRefreshToken(request); + String accessToken = accessTokenExtractor.extractToken(request); + + jwtProvider.validateToken(accessToken); + jwtProvider.validateToken(refreshToken); + + Long userId = Long.valueOf(jwtProvider.getSubject(refreshToken)); + return Accessor.user(userId); + } catch (RefreshTokenException e) { + return Accessor.guest(); + } + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/AccessTokenExtractor.java b/src/main/java/com/tasksprints/auction/domain/auth/AccessTokenExtractor.java new file mode 100644 index 00000000..cd2e0179 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/AccessTokenExtractor.java @@ -0,0 +1,23 @@ +package com.tasksprints.auction.domain.auth; + +import static com.tasksprints.auction.common.constant.ApiResponseMessages.ACCESS_TOKEN_NOT_FOUND; + +import com.tasksprints.auction.domain.auth.exception.AccessTokenException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +@Component +@Qualifier("accessTokenExtractor") +public class AccessTokenExtractor implements TokenExtractor { + private static final String TYPE = "Bearer "; + private static final String HEADER = "Authorization"; + + public String extractToken(HttpServletRequest request) { + String header = request.getHeader(HEADER); + if(header != null && header.startsWith(TYPE)) { + return header.substring(TYPE.length()); + } + throw new AccessTokenException(ACCESS_TOKEN_NOT_FOUND); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/TokenExtractor.java b/src/main/java/com/tasksprints/auction/domain/auth/TokenExtractor.java new file mode 100644 index 00000000..6dc30cfb --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/TokenExtractor.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth; + +import jakarta.servlet.http.HttpServletRequest; + +public interface TokenExtractor { + String extractToken(HttpServletRequest request); +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/tasksprints/auction/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 00000000..8f87b031 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,16 @@ +package com.tasksprints.auction.domain.auth.dto.request; + +import lombok.Builder; + + +public class LoginRequest { + @Builder + public record Login(String email, String password) { + public static Login of(String email, String password) { + return Login.builder() + .email(email) + .password(password) + .build(); + } + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/dto/response/AccessToken.java b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/AccessToken.java new file mode 100644 index 00000000..9f0dc450 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/AccessToken.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.dto.response; + +public record AccessToken(String accessToken) { + public static AccessToken of(String accessToken) { + return new AccessToken(accessToken); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/dto/response/ResponseTokens.java b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/ResponseTokens.java new file mode 100644 index 00000000..919123b1 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/ResponseTokens.java @@ -0,0 +1,14 @@ +package com.tasksprints.auction.domain.auth.dto.response; + +import lombok.Builder; +import org.springframework.http.ResponseCookie; + +@Builder +public record ResponseTokens(AccessToken accessToken, ResponseCookie refreshToken) { + public static ResponseTokens of(AccessToken accessToken, ResponseCookie refreshToken) { + return ResponseTokens.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/dto/response/UserTokens.java b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/UserTokens.java new file mode 100644 index 00000000..985065af --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/dto/response/UserTokens.java @@ -0,0 +1,18 @@ +package com.tasksprints.auction.domain.auth.dto.response; + +import lombok.*; + +@Getter +@Builder +@AllArgsConstructor +public class UserTokens { + private String refreshToken; + private AccessToken accessToken; + + public static UserTokens of(AccessToken accessToken, String refreshToken) { + return UserTokens.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/exception/AccessTokenException.java b/src/main/java/com/tasksprints/auction/domain/auth/exception/AccessTokenException.java new file mode 100644 index 00000000..c4acac9f --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/exception/AccessTokenException.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.exception; + +public class AccessTokenException extends AuthException { + public AccessTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/exception/AuthException.java b/src/main/java/com/tasksprints/auction/domain/auth/exception/AuthException.java new file mode 100644 index 00000000..ce842a7e --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/exception/AuthException.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.exception; + +public class AuthException extends RuntimeException { + public AuthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/exception/RefreshTokenException.java b/src/main/java/com/tasksprints/auction/domain/auth/exception/RefreshTokenException.java new file mode 100644 index 00000000..c0c1d255 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/exception/RefreshTokenException.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.exception; + +public class RefreshTokenException extends AuthException { + public RefreshTokenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/model/Accessor.java b/src/main/java/com/tasksprints/auction/domain/auth/model/Accessor.java new file mode 100644 index 00000000..14e00fda --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/model/Accessor.java @@ -0,0 +1,32 @@ +package com.tasksprints.auction.domain.auth.model; + +import lombok.Builder; + +@Builder +public record Accessor(Long userId, Role role) { + + private static final Long GUEST_USER_ID = 0L; + + public static Accessor guest() { + return Accessor.of(GUEST_USER_ID, Role.GUEST); + } + + public static Accessor user(Long userId) { + return Accessor.of(userId, Role.USER); + } + + public static Accessor admin(Long userId) { + return Accessor.of(userId, Role.ADMIN); + } + + public boolean isUser() { + return Role.USER.equals(role); + } + + private static Accessor of(Long userId, Role role) { + return Accessor.builder() + .userId(userId) + .role(role) + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/model/RefreshToken.java b/src/main/java/com/tasksprints/auction/domain/auth/model/RefreshToken.java new file mode 100644 index 00000000..edac44b6 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/model/RefreshToken.java @@ -0,0 +1,30 @@ +package com.tasksprints.auction.domain.auth.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + @Id + private String id; + + @Column + private Long userId; + + public static RefreshToken of(String id, Long userId) { + return RefreshToken.builder() + .id(id) + .userId(userId) + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/model/Role.java b/src/main/java/com/tasksprints/auction/domain/auth/model/Role.java new file mode 100644 index 00000000..5591e894 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/model/Role.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.model; + +public enum Role { + USER, + ADMIN, + GUEST +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..7259764b --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,7 @@ +package com.tasksprints.auction.domain.auth.repository; + +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/service/AuthService.java b/src/main/java/com/tasksprints/auction/domain/auth/service/AuthService.java new file mode 100644 index 00000000..30533708 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/service/AuthService.java @@ -0,0 +1,11 @@ +package com.tasksprints.auction.domain.auth.service; + +import com.tasksprints.auction.domain.auth.dto.response.ResponseTokens; + +public interface AuthService { + Long validateLogin(String email, String password); + + ResponseTokens issueResponseTokens(Long userId); + + ResponseTokens reissueResponseTokens(String refreshToken); +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/tasksprints/auction/domain/auth/service/AuthServiceImpl.java new file mode 100644 index 00000000..f4fced71 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,48 @@ +package com.tasksprints.auction.domain.auth.service; + +import com.tasksprints.auction.common.jwt.JwtProvider; +import com.tasksprints.auction.domain.auth.dto.response.ResponseTokens; +import com.tasksprints.auction.domain.auth.dto.response.UserTokens; +import com.tasksprints.auction.domain.auth.exception.AuthException; +import com.tasksprints.auction.domain.user.dto.response.UserDetailResponse; +import com.tasksprints.auction.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + private final UserService userService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + + @Override + public Long validateLogin(String email, String password) { + UserDetailResponse userDetail = userService.getUserDetailByEmail(email); + + if (!password.equals(userDetail.getPassword())) { + throw new AuthException("password is not correct"); + } + return userDetail.getId(); + } + + @Transactional + @Override + public ResponseTokens issueResponseTokens(Long userId) { + UserTokens tokens = jwtProvider.generateToken(userId.toString()); + refreshTokenService.saveRefreshToken(tokens.getRefreshToken(), userId); + ResponseCookie refreshToken = refreshTokenService.getResponseRefreshToken(tokens.getRefreshToken()); + + return ResponseTokens.of(tokens.getAccessToken(), refreshToken); + } + + @Transactional + @Override + public ResponseTokens reissueResponseTokens(String refreshToken) { + Long userId = refreshTokenService.findRefreshTokenById(refreshToken).getUserId(); + return issueResponseTokens(userId); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManager.java b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManager.java new file mode 100644 index 00000000..d84f41f9 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManager.java @@ -0,0 +1,45 @@ +package com.tasksprints.auction.domain.auth.service; + +import static com.tasksprints.auction.common.constant.ApiResponseMessages.REFRESH_TOKEN_NOT_FOUND; + +import com.tasksprints.auction.domain.auth.exception.RefreshTokenException; +import com.tasksprints.auction.domain.auth.repository.RefreshTokenRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenCookieManager { + private static final Integer COOKIE_AGE_SECONDS = 1209600; + private static final String COOKIE_NAME = "refresh-token"; + + private final RefreshTokenRepository refreshTokenRepository; + + public String extractRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + throw new RefreshTokenException(REFRESH_TOKEN_NOT_FOUND); + } + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(COOKIE_NAME)) + .filter(cookie -> refreshTokenRepository.existsById(cookie.getValue())) + .findFirst() + .orElseThrow(() -> new RefreshTokenException(REFRESH_TOKEN_NOT_FOUND)) + .getValue(); + } + + public ResponseCookie createResponseCookie(String refreshToken) { + return ResponseCookie.from(COOKIE_NAME, refreshToken) + .maxAge(COOKIE_AGE_SECONDS) + .secure(true) + .httpOnly(true) + .sameSite("None") + .path("/") + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenService.java b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenService.java new file mode 100644 index 00000000..30e8e1c8 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenService.java @@ -0,0 +1,12 @@ +package com.tasksprints.auction.domain.auth.service; + +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import org.springframework.http.ResponseCookie; + +public interface RefreshTokenService { + RefreshToken saveRefreshToken(String refreshTokenValue, Long userId); + + ResponseCookie getResponseRefreshToken(String refresh); + + RefreshToken findRefreshTokenById(String refreshToken); +} diff --git a/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImpl.java b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImpl.java new file mode 100644 index 00000000..b23fb021 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImpl.java @@ -0,0 +1,35 @@ +package com.tasksprints.auction.domain.auth.service; + +import com.tasksprints.auction.domain.auth.exception.RefreshTokenException; +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import com.tasksprints.auction.domain.auth.repository.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class RefreshTokenServiceImpl implements RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenCookieManager cookieManager; + + @Override + @Transactional + public RefreshToken saveRefreshToken(String refreshTokenValue, Long userId) { + RefreshToken refreshToken = RefreshToken.of(refreshTokenValue, userId); + return refreshTokenRepository.save(refreshToken); + } + + @Override + public ResponseCookie getResponseRefreshToken(String refreshToken) { + return cookieManager.createResponseCookie(refreshToken); + } + + @Override + public RefreshToken findRefreshTokenById(String refreshToken) { + return refreshTokenRepository.findById(refreshToken) + .orElseThrow(() -> new RefreshTokenException("Invalid refresh token")); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java b/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java deleted file mode 100644 index 13d8e420..00000000 --- a/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.tasksprints.auction.domain.user.model; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum UserRole { - ADMIN("admin"), - USER("user"); - - private final String userRole; -} diff --git a/src/main/java/com/tasksprints/auction/domain/user/repository/UserRepository.java b/src/main/java/com/tasksprints/auction/domain/user/repository/UserRepository.java index 007cf110..ff883b64 100644 --- a/src/main/java/com/tasksprints/auction/domain/user/repository/UserRepository.java +++ b/src/main/java/com/tasksprints/auction/domain/user/repository/UserRepository.java @@ -1,7 +1,9 @@ package com.tasksprints.auction.domain.user.repository; import com.tasksprints.auction.domain.user.model.User; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/tasksprints/auction/domain/user/service/UserService.java b/src/main/java/com/tasksprints/auction/domain/user/service/UserService.java index 9ac79052..3c8c4d06 100644 --- a/src/main/java/com/tasksprints/auction/domain/user/service/UserService.java +++ b/src/main/java/com/tasksprints/auction/domain/user/service/UserService.java @@ -19,5 +19,7 @@ public interface UserService { void deleteUser(Long id); + UserDetailResponse getUserDetailByEmail(String email); + User getUserById(Long id); } diff --git a/src/main/java/com/tasksprints/auction/domain/user/service/UserServiceImpl.java b/src/main/java/com/tasksprints/auction/domain/user/service/UserServiceImpl.java index 4f96751b..6d2e5302 100644 --- a/src/main/java/com/tasksprints/auction/domain/user/service/UserServiceImpl.java +++ b/src/main/java/com/tasksprints/auction/domain/user/service/UserServiceImpl.java @@ -64,9 +64,16 @@ public void deleteUser(Long id) { userRepository.save(user); // 상태 업데이트를 저장 } + @Override + public UserDetailResponse getUserDetailByEmail(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("User not found with email " + email)); + return UserDetailResponse.of(user); + } + @Override public User getUserById(Long id) { - return userRepository.findById(id) + return userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id " + id)); } } diff --git a/src/test/java/com/tasksprints/auction/api/AuctionControllerTest.java b/src/test/java/com/tasksprints/auction/api/AuctionControllerTest.java index 9cc04755..c0cd0319 100644 --- a/src/test/java/com/tasksprints/auction/api/AuctionControllerTest.java +++ b/src/test/java/com/tasksprints/auction/api/AuctionControllerTest.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -40,10 +41,10 @@ @WebMvcTest(AuctionController.class) @MockBean(JpaMetamodelMappingContext.class) -public class AuctionControllerTest { +public class AuctionControllerTest extends BaseControllerTest { @Autowired - private MockMvc mockMvc; + protected MockMvc mockMvc; @MockBean private AuctionService auctionService; diff --git a/src/test/java/com/tasksprints/auction/api/AuthControllerTest.java b/src/test/java/com/tasksprints/auction/api/AuthControllerTest.java new file mode 100644 index 00000000..1348acea --- /dev/null +++ b/src/test/java/com/tasksprints/auction/api/AuthControllerTest.java @@ -0,0 +1,173 @@ +package com.tasksprints.auction.api; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tasksprints.auction.api.BaseControllerTest; +import com.tasksprints.auction.api.auth.AuthController; +import com.tasksprints.auction.common.constant.ApiResponseMessages; +import com.tasksprints.auction.domain.auth.dto.request.LoginRequest; +import com.tasksprints.auction.domain.auth.dto.request.LoginRequest.Login; +import com.tasksprints.auction.domain.auth.dto.response.AccessToken; +import com.tasksprints.auction.domain.auth.dto.response.ResponseTokens; +import com.tasksprints.auction.domain.auth.dto.response.UserTokens; +import com.tasksprints.auction.domain.auth.exception.AuthException; +import com.tasksprints.auction.domain.auth.model.Accessor; +import com.tasksprints.auction.domain.auth.service.AuthService; +import com.tasksprints.auction.domain.user.exception.UserNotFoundException; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + + +@WebMvcTest(AuthController.class) +@MockBean(JpaMetamodelMappingContext.class) +class AuthControllerTest extends BaseControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private AuthService authService; + + @Autowired + private ObjectMapper objectMapper; + + static ResponseCookie createResponseCookie() { + return ResponseCookie.from("refresh-token", "refreshTokenValue") + .maxAge(3600) + .secure(true) + .httpOnly(true) + .sameSite("None") + .path("/") + .build(); + } + + + @Nested + @DisplayName("Test login") + class LoginTest { + private final ResponseCookie responseCookie = ResponseCookie.from("refresh-token", "refreshTokenValue") + .maxAge(3600) + .secure(true) + .httpOnly(true) + .sameSite("None") + .path("/") + .build(); + + + @Test + @DisplayName("Return refresh and access token, when login success") + void login_success() throws Exception { + // given + AccessToken accessToken = AccessToken.of("accessTokenValue"); + UserTokens tokens = UserTokens.of(accessToken, "refreshTokenValue"); + LoginRequest.Login request = new Login("example@email.com", "password"); + ResponseTokens responseTokens = ResponseTokens.of(accessToken, responseCookie); + when(authService.validateLogin(any(), any())).thenReturn(1L); + when(authService.issueResponseTokens(any())).thenReturn(responseTokens); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessToken").value("accessTokenValue")) + .andExpect(jsonPath("$.message").value(ApiResponseMessages.LOGIN_SUCCESS)) + .andExpect(header().string("set-cookie", containsString("refresh-token=refreshTokenValue"))) + .andExpect(header().string("set-cookie", containsString("Max-Age=3600"))) + .andExpect(header().string("set-cookie", containsString("Secure"))) + .andExpect(header().string("set-cookie", containsString("HttpOnly"))) + .andExpect(header().string("set-cookie", containsString("SameSite=None"))); + } + + @Test + @DisplayName("Throw Exception, when password is different") + void loginFailWhenPasswordIsDifferent() throws Exception { + // given + LoginRequest.Login request = new Login("example@email.com", "password"); + when(authService.validateLogin(any(), any())).thenThrow(AuthException.class); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(ApiResponseMessages.USER_NOT_FOUND)); + } + + @Test + @DisplayName("Throw Exception, when email is different") + void loginFailWhenEmailIsDifferent() throws Exception { + // given + LoginRequest.Login request = new Login("example@email.com", "password"); + when(authService.validateLogin(any(), any())).thenThrow(UserNotFoundException.class); + + // when + ResultActions resultActions = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value(ApiResponseMessages.USER_NOT_FOUND)); + } + } + + @Nested + @DisplayName("Test reissueToken") + class ReissueTest { + // given + Long userId = 1L; + AccessToken accessToken = AccessToken.of("accessTokenValue"); + ResponseTokens responseTokens = ResponseTokens.of(accessToken, createResponseCookie()); + Cookie cookie = new Cookie("refresh-token", "refreshToken"); + @Test + @DisplayName("If refresh token exist in repository, return refresh token") + void testReissue_success() throws Exception { + // given + when(authService.reissueResponseTokens(any())).thenReturn(responseTokens); + + // when + ResultActions resultActions = mockMvc.perform(get("/api/v1/auth/reissue") + .contentType(MediaType.APPLICATION_JSON) + .cookie(cookie) + ); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessToken").value("accessTokenValue")) + .andExpect(jsonPath("$.message").value(ApiResponseMessages.LOGIN_SUCCESS)) + .andExpect(header().string("set-cookie", containsString("refresh-token=refreshTokenValue"))) + .andExpect(header().string("set-cookie", containsString("Max-Age=3600"))) + .andExpect(header().string("set-cookie", containsString("Secure"))) + .andExpect(header().string("set-cookie", containsString("HttpOnly"))) + .andExpect(header().string("set-cookie", containsString("SameSite=None"))); + } + } +} + diff --git a/src/test/java/com/tasksprints/auction/api/BaseControllerTest.java b/src/test/java/com/tasksprints/auction/api/BaseControllerTest.java new file mode 100644 index 00000000..58a4aba1 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/api/BaseControllerTest.java @@ -0,0 +1,10 @@ +package com.tasksprints.auction.api; + +import com.tasksprints.auction.common.config.TestAuthConfig; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; + +@WebMvcTest +@Import(TestAuthConfig.class) +public abstract class BaseControllerTest { +} diff --git a/src/test/java/com/tasksprints/auction/api/ProductControllerTest.java b/src/test/java/com/tasksprints/auction/api/ProductControllerTest.java index 54212bc5..6d56ebc6 100644 --- a/src/test/java/com/tasksprints/auction/api/ProductControllerTest.java +++ b/src/test/java/com/tasksprints/auction/api/ProductControllerTest.java @@ -28,7 +28,7 @@ @WebMvcTest(ProductController.class) @MockBean(JpaMetamodelMappingContext.class) -public class ProductControllerTest { +public class ProductControllerTest extends BaseControllerTest { @Autowired private MockMvc mockMvc; diff --git a/src/test/java/com/tasksprints/auction/api/UserControllerTest.java b/src/test/java/com/tasksprints/auction/api/UserControllerTest.java index 3b3dfac5..5d396853 100644 --- a/src/test/java/com/tasksprints/auction/api/UserControllerTest.java +++ b/src/test/java/com/tasksprints/auction/api/UserControllerTest.java @@ -30,7 +30,7 @@ @WebMvcTest(UserController.class) @MockBean(JpaMetamodelMappingContext.class) -class UserControllerTest { +class UserControllerTest extends BaseControllerTest { @Autowired private MockMvc mockMvc; diff --git a/src/test/java/com/tasksprints/auction/common/config/TestAuthConfig.java b/src/test/java/com/tasksprints/auction/common/config/TestAuthConfig.java new file mode 100644 index 00000000..e1261044 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/common/config/TestAuthConfig.java @@ -0,0 +1,19 @@ +package com.tasksprints.auction.common.config; + +import com.tasksprints.auction.common.jwt.JwtProvider; +import com.tasksprints.auction.domain.auth.TokenExtractor; +import com.tasksprints.auction.domain.auth.service.RefreshTokenCookieManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; + +@TestConfiguration +public class TestAuthConfig { + @MockBean + public JwtProvider jwtProvider; + + @MockBean + public RefreshTokenCookieManager refreshTokenCookieManager; + + @MockBean + public TokenExtractor accessTokenExtractor; +} diff --git a/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java b/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java index ea1bafc5..96860980 100644 --- a/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java +++ b/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java @@ -1,6 +1,7 @@ package com.tasksprints.auction.common.jwt; -import com.tasksprints.auction.common.jwt.dto.response.JwtResponse; +import com.tasksprints.auction.common.config.JwtConfig; +import com.tasksprints.auction.domain.auth.dto.response.UserTokens; import io.jsonwebtoken.ExpiredJwtException; import java.time.Clock; import java.time.Instant; @@ -21,7 +22,7 @@ @ExtendWith(MockitoExtension.class) class JwtProviderTest { @Mock - private JwtProperties jwtProperties; + private JwtConfig jwtConfig; @Mock private Clock clock; @InjectMocks @@ -38,77 +39,79 @@ class JwtProviderTest { public void setUp() { when(clock.instant()).thenReturn(Instant.now()); when(clock.getZone()).thenReturn(ZONE_ID); - when(jwtProperties.getIssuer()).thenReturn(ISSUER); - when(jwtProperties.getSecretKey()).thenReturn(SECRET_KEY); + when(jwtConfig.getIssuer()).thenReturn(ISSUER); + when(jwtConfig.getSecretKey()).thenReturn(SECRET_KEY); } private void stubAccessTokenExpiration(Long expireMs) { - when(jwtProperties.getExpireMs()).thenReturn(expireMs); + when(jwtConfig.getAccessExpireMs()).thenReturn(expireMs); } private void stubRefreshTokenExpiration(Long expireMs) { - when(jwtProperties.getRefreshExpireMs()).thenReturn(expireMs); + when(jwtConfig.getRefreshExpireMs()).thenReturn(expireMs); } @Test - @DisplayName("token generator 을 통한 access token, refresh token 발급 테스트") + @DisplayName("accessToken과 refreshToken을 발급해야한다.") void generateToken() { - JwtResponse jwtResponse = jwtProvider.generateToken(1L, "admin"); + // when + UserTokens userTokens = jwtProvider.generateToken("1L"); - assertNotNull(jwtResponse.getAccessToken(), "access token 이 발급되어야 합니다."); - assertNotNull(jwtResponse.getRefreshToken(), "refresh token 이 발급되어야 합니다."); + // then + assertNotNull(userTokens.getAccessToken(), "access token 이 발급되어야 합니다."); + assertNotNull(userTokens.getRefreshToken(), "refresh token 이 발급되어야 합니다."); } @Test - @DisplayName("access token 발급 테스트") - void createAccessToken() { - stubAccessTokenExpiration(VALID_EXPIRE_MS); - - String token = jwtProvider.createAccessToken(1L, "admin"); - - assertNotNull(token, "access token 이 발급되어야 합니다."); - } - - @Test - @DisplayName("refresh token 발급 테스트") - void createRefreshToken() { - stubRefreshTokenExpiration(REFRESH_EXPIRE_MS); - String token = jwtProvider.createRefreshToken(); - assertNotNull(token, "refresh token 이 발급되어야 합니다."); - } - - @Test - @DisplayName("유효한 토큰 테스트") + @DisplayName("유효기간에는 토큰이 유효해야한다.") void verifyToken_valid() { + // given stubAccessTokenExpiration(VALID_EXPIRE_MS); + stubRefreshTokenExpiration(REFRESH_EXPIRE_MS); - String token = jwtProvider.createAccessToken(1L, "admin"); + // when + UserTokens userTokens = jwtProvider.generateToken("1L"); - Assertions.assertTrue(jwtProvider.verifyToken(token)); + // then + Assertions.assertDoesNotThrow(() -> { + jwtProvider.validateToken(userTokens.getAccessToken().accessToken()); + }); + Assertions.assertDoesNotThrow(() -> { + jwtProvider.validateToken(userTokens.getRefreshToken()); + }); } @Test - @DisplayName("만료된 토큰 테스트") + @DisplayName("유효기간이 지나면, 토큰 만료 예외를 반환해야한다") void verifyToken_expired() { + // given stubAccessTokenExpiration(EXPIRED_EXPIRE_MS); + stubRefreshTokenExpiration(EXPIRED_EXPIRE_MS); + + // when + UserTokens userTokens = jwtProvider.generateToken("1L"); - String token = jwtProvider.createAccessToken(1L, "admin"); + // then + Assertions.assertThrows(ExpiredJwtException.class, () -> { + jwtProvider.validateToken(userTokens.getRefreshToken()); + }, "리프레시토큰이 즉시 만료되어야 합니다."); Assertions.assertThrows(ExpiredJwtException.class, () -> { - jwtProvider.verifyToken(token); - }, "토큰이 즉시 만료되어야 합니다."); + jwtProvider.validateToken(userTokens.getAccessToken().accessToken()); + }, "액세스토큰이 즉시 만료되어야 합니다."); } @Test @DisplayName("디코딩 된 페이로드 정확성 테스트") void getClaims() { + // given stubAccessTokenExpiration(VALID_EXPIRE_MS); + UserTokens userTokens = jwtProvider.generateToken("1L"); - String token = jwtProvider.createAccessToken(1L, "admin"); - Long decodedUserId = jwtProvider.getClaims(token).get("userId", Long.class); - String decodedUserRole = jwtProvider.getClaims(token).get("userRole", String.class); + // when + String decodedUserId = jwtProvider.getSubject(userTokens.getAccessToken().accessToken()); - assertThat(decodedUserId).isEqualTo(1L); - assertThat(decodedUserRole).isEqualTo("admin"); + // then + assertThat(decodedUserId).isEqualTo("1L"); } } diff --git a/src/test/java/com/tasksprints/auction/domain/auth/AccessTokenExtractorTest.java b/src/test/java/com/tasksprints/auction/domain/auth/AccessTokenExtractorTest.java new file mode 100644 index 00000000..3efe9fc7 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/domain/auth/AccessTokenExtractorTest.java @@ -0,0 +1,48 @@ +package com.tasksprints.auction.domain.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.tasksprints.auction.domain.auth.exception.AccessTokenException; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AccessTokenExtractorTest { + private TokenExtractor tokenExtractor; + + @BeforeEach + void setUp() { + tokenExtractor = new AccessTokenExtractor(); + } + + @Test + @DisplayName("헤더에서 access token 을 꺼낸다") + void testExtractToken_success() { + // given + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("Authorization")).thenReturn("Bearer token"); + + // when + String accessToken = tokenExtractor.extractToken(request); + + // then + assertThat(accessToken).isEqualTo("token"); + } + + @Test + @DisplayName("access token 이 존재하지 않으면 예외를 반환한다") + void testExtractToken_fail() { + // given + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("Authorization")).thenReturn("notoken"); + + // when, then + Assertions.assertThrows(AccessTokenException.class, () -> { + tokenExtractor.extractToken(request); + }); + } +} diff --git a/src/test/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepositoryTest.java b/src/test/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepositoryTest.java new file mode 100644 index 00000000..261eae51 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/domain/auth/repository/RefreshTokenRepositoryTest.java @@ -0,0 +1,60 @@ +package com.tasksprints.auction.domain.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.tasksprints.auction.common.config.QueryDslConfig; +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + + +@DataJpaTest +@Import(QueryDslConfig.class) +class RefreshTokenRepositoryTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + private RefreshToken refreshToken; + + @BeforeEach + void setUp() { + refreshToken = RefreshToken.builder() + .id("testId") + .userId(1L) + .build(); + } + + @Test + @DisplayName("refresh token ID로 refresh token 조회") + void testFindById() { + // given + refreshTokenRepository.save(refreshToken); + + // when + Optional resultRefreshToken = refreshTokenRepository.findById(refreshToken.getId()); + + // then + assertTrue(resultRefreshToken.isPresent()); + assertThat(refreshToken.getId()).isEqualTo(resultRefreshToken.get().getId()); + } + + @Test + @DisplayName("refresh token ID로 refresh token이 존재하는지 확인합니다.") + void testExistById() { + // given + refreshTokenRepository.save(refreshToken); + + // when + boolean resultRefreshToken = refreshTokenRepository.existsById(refreshToken.getId()); + + // then + assertTrue(resultRefreshToken); + } +} diff --git a/src/test/java/com/tasksprints/auction/domain/auth/service/AuthServiceImplTest.java b/src/test/java/com/tasksprints/auction/domain/auth/service/AuthServiceImplTest.java new file mode 100644 index 00000000..b7347c23 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/domain/auth/service/AuthServiceImplTest.java @@ -0,0 +1,136 @@ +package com.tasksprints.auction.domain.auth.service; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.tasksprints.auction.common.jwt.JwtProvider; +import com.tasksprints.auction.domain.auth.dto.response.AccessToken; +import com.tasksprints.auction.domain.auth.dto.response.ResponseTokens; +import com.tasksprints.auction.domain.auth.dto.response.UserTokens; +import com.tasksprints.auction.domain.auth.exception.AuthException; +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import com.tasksprints.auction.domain.user.dto.response.UserDetailResponse; +import com.tasksprints.auction.domain.user.model.User; +import com.tasksprints.auction.domain.user.service.UserService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseCookie; + + +@ExtendWith(MockitoExtension.class) +class AuthServiceImplTest { + + @Mock + UserService userService; + @Mock + JwtProvider jwtProvider; + @Mock + RefreshTokenService refreshTokenService; + @InjectMocks + private AuthServiceImpl authService; + + private UserDetailResponse userDetail; + + private String loginEmail; + + @BeforeEach + void setUp() { + loginEmail = "user@exapmle.com"; + User existingUser = User.builder() + .id(1L) + .email("user@exapmle.com") + .password("password") + .nickName("testUser") + .name("realName") + .build(); + + userDetail = UserDetailResponse.of(existingUser); + } + + public static ResponseCookie createResponseCookie(String value) { + return ResponseCookie.from("refresh-token", value) + .maxAge(3600) + .secure(true) + .httpOnly(true) + .sameSite("None") + .path("/") + .build(); + } + + @Nested + @DisplayName("Validate login information test") + class TestLogin { + @Test + @DisplayName("Return userDetails, when password same") + void validateLogin_success() { + // given + String password = "password"; + when(userService.getUserDetailByEmail(any())).thenReturn(userDetail); + + // when + Long actualUserId = authService.validateLogin(loginEmail, password); + + // then + assertEquals(1L, actualUserId); + } + + @Test + @DisplayName("Should throw exception when password is different") + void validateLoginDifferentPassword() { + // given + String password = "differentPassword"; + when(userService.getUserDetailByEmail(any())).thenReturn(userDetail); + + // when + AuthException exception = assertThrows(AuthException.class, () -> { + authService.validateLogin(loginEmail, password); + }); + + // then + assertEquals("password is not correct", exception.getMessage()); + } + } + + @Nested + @DisplayName("Issue response tokens test") + class TestIssueTokens { + public static AccessToken createAccessToken(String value) { + return new AccessToken(value); + } + + public static UserTokens createUserTokens(String accessTokenValue, String refreshTokenValue) { + return UserTokens.of(createAccessToken(accessTokenValue), refreshTokenValue); + } + + @Test + @DisplayName("Return tokens, when issue tokens successfully") + void returnResponseTokens_success() { + // given + String refreshTokenValue = "refreshTokenValue"; + String accessTokenValue = "accessTokenValue"; + UserTokens generatedTokens = createUserTokens(accessTokenValue, refreshTokenValue); + Long userId = 1L; + RefreshToken refreshToken = new RefreshToken(refreshTokenValue, userId); + + when(jwtProvider.generateToken(any())).thenReturn(generatedTokens); + when(refreshTokenService.saveRefreshToken(any(), any())).thenReturn(refreshToken); + when(refreshTokenService.getResponseRefreshToken(any())).thenReturn(createResponseCookie(refreshTokenValue)); + + // when + ResponseTokens responseTokens = authService.issueResponseTokens(userId); + + // then + assertNotNull(responseTokens); + assertNotNull(responseTokens.accessToken()); + assertNotNull(responseTokens.refreshToken()); + } + } +} diff --git a/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManagerTest.java b/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManagerTest.java new file mode 100644 index 00000000..20710fe6 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenCookieManagerTest.java @@ -0,0 +1,97 @@ +package com.tasksprints.auction.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.tasksprints.auction.domain.auth.exception.RefreshTokenException; +import com.tasksprints.auction.domain.auth.repository.RefreshTokenRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseCookie; + +class RefreshTokenCookieManagerTest { + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @InjectMocks + private RefreshTokenCookieManager cookieManager; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Nested + @DisplayName("") + class TestExtractToken { + @Test + @DisplayName("Return refresh token, after extracting refresh token") + void extractRefreshToken_success() { + // given + Cookie[] cookies = { + new Cookie("nothing", "token"), + new Cookie("refresh-token", "tokenName"), + }; + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getCookies()).thenReturn(cookies); + when(refreshTokenRepository.existsById("tokenName")).thenReturn(true); + + // when + String resultValue = cookieManager.extractRefreshToken(request); + + // then + assertThat(resultValue).isEqualTo("tokenName"); + } + + @Test + @DisplayName("Should throw exception, when refresh token doesn't exist in cookie") + void extractRefreshToken_fail() { + // given + Cookie[] cookies = { + new Cookie("nothing", "token"), + }; + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getCookies()).thenReturn(cookies); + + // when, then + Assertions.assertThrows(RefreshTokenException.class, () -> { + cookieManager.extractRefreshToken(request); + }, "리프레시토큰이 쿠키에 존재해야 합니다."); + } + + } + + + @Nested + @DisplayName("Get response cookie test") + class TestResponseCookie { + + @Test + @DisplayName("Return ResponseCookie, when creating the cookie successfully") + void returnResponseCookie_success() { + // given + String refreshToken = "refreshTokenValue"; + + // when + ResponseCookie responseCookie = cookieManager.createResponseCookie(refreshToken); + + // then + assertEquals(1209600, responseCookie.getMaxAge().toSeconds()); + assertTrue(responseCookie.isSecure()); + assertTrue(responseCookie.isHttpOnly()); + assertEquals("None", responseCookie.getSameSite()); + assertEquals("/", responseCookie.getPath()); + } + } +} diff --git a/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImplTest.java b/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImplTest.java new file mode 100644 index 00000000..64de817d --- /dev/null +++ b/src/test/java/com/tasksprints/auction/domain/auth/service/RefreshTokenServiceImplTest.java @@ -0,0 +1,78 @@ +package com.tasksprints.auction.domain.auth.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.tasksprints.auction.domain.auth.exception.RefreshTokenException; +import com.tasksprints.auction.domain.auth.model.RefreshToken; +import com.tasksprints.auction.domain.auth.repository.RefreshTokenRepository; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceImplTest { + + @Mock + RefreshTokenRepository refreshTokenRepository; + + @InjectMocks + RefreshTokenServiceImpl refreshTokenService; + + @Nested + @DisplayName("Save RefreshToken test") + class TestSaveRefreshToken { + @Test + @DisplayName("should return RefreshToken, when the test is success") + void testSaveRefreshToken_success() { + // given + String refreshTokenValue = "refreshToken"; + Long userId = 1L; + RefreshToken expectedRefreshToken = RefreshToken.of(refreshTokenValue, userId); + when(refreshTokenRepository.save(any())).thenReturn(expectedRefreshToken); + + // when + RefreshToken actualRefreshToken = refreshTokenService.saveRefreshToken(refreshTokenValue, userId); + + // then + assertEquals(expectedRefreshToken.getUserId(), actualRefreshToken.getUserId()); + assertEquals(expectedRefreshToken.getId(), actualRefreshToken.getId()); + } + } + + @Nested + @DisplayName("Find refresh Token test") + class TestFindRefreshToken { + + void testFindRefreshToken_success() { + // given + String refreshTokenValue = "refreshToken"; + RefreshToken existedRefreshToken = RefreshToken.of("refreshToken", 1L); + when(refreshTokenRepository.findById(any())).thenReturn(Optional.ofNullable(existedRefreshToken)); + + // when + RefreshToken foundRefreshToken = refreshTokenService.findRefreshTokenById(refreshTokenValue); + + // then + assertEquals(1L, foundRefreshToken.getUserId()); + assertEquals(refreshTokenValue, foundRefreshToken.getId()); + } + + void testFindRefreshToken_fail() { + // given + String refreshTokenValue = "refreshToken"; + when(refreshTokenRepository.findById(any())).thenReturn(Optional.empty()); + + // when, then + Assertions.assertThrows(RefreshTokenException.class, () -> { + refreshTokenService.findRefreshTokenById(refreshTokenValue); + }); + } + } +} diff --git a/src/test/java/com/tasksprints/auction/domain/user/UserRepositoryTest.java b/src/test/java/com/tasksprints/auction/domain/user/UserRepositoryTest.java index 45f64ea9..960400d1 100644 --- a/src/test/java/com/tasksprints/auction/domain/user/UserRepositoryTest.java +++ b/src/test/java/com/tasksprints/auction/domain/user/UserRepositoryTest.java @@ -95,4 +95,25 @@ void createUser() { log.info("Created User: {}", createdUser); } } + + + @DisplayName("find User By Email") + @Test + void findUserByEmail() { + // given + User createdUser = userRepository.save(user); + String findEmail = "test@example.com"; + + // when + User foundUser = userRepository.findByEmail(findEmail).orElse(null); + + // then + Assertions.assertNotNull(foundUser); + Assertions.assertEquals(createdUser.getId(), foundUser.getId()); + Assertions.assertEquals("testUser", foundUser.getName()); + Assertions.assertEquals("testNick", foundUser.getNickName()); + Assertions.assertEquals("test@example.com", foundUser.getEmail()); + + log.info("Found User: {}", foundUser); + } } diff --git a/src/test/java/com/tasksprints/auction/domain/user/UserServiceImplTest.java b/src/test/java/com/tasksprints/auction/domain/user/UserServiceImplTest.java index 37db4e07..585e4067 100644 --- a/src/test/java/com/tasksprints/auction/domain/user/UserServiceImplTest.java +++ b/src/test/java/com/tasksprints/auction/domain/user/UserServiceImplTest.java @@ -181,4 +181,43 @@ void shouldThrowExceptionWhenUserNotFound() { verify(userRepository, never()).delete(any(User.class)); } } + + @Nested + @DisplayName("Get User By Email") + class GetUserByEmailTests { + + @Test + @DisplayName("Get user's detail information by email") + void shouldReturnUserWhenFound() { + // given + String findEmail = "test@example.com"; + when(userRepository.findByEmail(any())).thenReturn(Optional.ofNullable(existingUser)); + + // when + UserDetailResponse user = userService.getUserDetailByEmail(findEmail); + + // then + Assertions.assertNotNull(user); + Assertions.assertEquals(existingUser.getId(), user.getId()); + Assertions.assertEquals(existingUser.getName(), user.getName()); + verify(userRepository, times(1)).findByEmail(findEmail); + } + + @Test + @DisplayName("Should throw an exception if the user cannot be found by email") + void shouldThrowExceptionWhenUserNotFound() { + // given + String findEmail = "different@example.com"; + when(userRepository.findByEmail(any())).thenReturn(Optional.empty()); + + // when + UserNotFoundException exception = Assertions.assertThrows(UserNotFoundException.class, () -> { + userService.getUserDetailByEmail(findEmail); + }); + + // then + Assertions.assertEquals("User not found with email " + findEmail, exception.getMessage()); + verify(userRepository, times(1)).findByEmail(findEmail); + } + } }