diff --git a/src/main/java/org/runimo/runimo/auth/controller/AuthController.java b/src/main/java/org/runimo/runimo/auth/controller/AuthController.java new file mode 100644 index 00000000..f084518c --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/AuthController.java @@ -0,0 +1,109 @@ +package org.runimo.runimo.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.controller.request.AppleLoginRequest; +import org.runimo.runimo.auth.controller.request.AuthSignupRequest; +import org.runimo.runimo.auth.controller.request.KakaoLoginRequest; +import org.runimo.runimo.auth.service.OidcService; +import org.runimo.runimo.auth.service.SignUpUsecase; +import org.runimo.runimo.auth.service.TokenRefreshService; +import org.runimo.runimo.auth.service.dtos.AuthResponse; +import org.runimo.runimo.auth.service.dtos.SignupUserResponse; +import org.runimo.runimo.auth.service.dtos.TokenPair; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@Tag(name = "Auth API", description = "인증 관련 API 모음") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final OidcService oidcService; + private final TokenRefreshService tokenRefreshService; + private final SignUpUsecase signUpUsecase; + + @Operation(summary = "카카오 소셜 로그인", description = "카카오 OIDC 토큰을 이용하여 로그인합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "등록되지 않은 사용자") + }) + @PostMapping("/kakao") + public ResponseEntity> kakaoLogin( + @RequestBody KakaoLoginRequest request + ) { + AuthResponse res = oidcService.kakaoLogin(request.oidcToken()); + return ResponseEntity.ok().body( + SuccessResponse.of( + UserHttpResponseCode.LOGIN_SUCCESS, + res + ) + ); + } + + @Operation(summary = "애플 소셜 로그인", description = "애플 OIDC 토큰을 이용하여 로그인합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "등록되지 않은 사용자") + }) + @PostMapping("/apple") + public ResponseEntity> appleLogin( + @RequestBody final AppleLoginRequest request + ) { + AuthResponse res = oidcService.appleLogin(request.authCode(), request.codeVerifier()); + return ResponseEntity.ok().body( + SuccessResponse.of( + UserHttpResponseCode.LOGIN_SUCCESS, + res + ) + ); + } + + @Operation(summary = "사용자 회원가입 및 로그인", description = "사용자가 OIDC 토큰을 사용하여 회원가입 후 로그인합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "회원가입 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "409", description = "이미 존재하는 사용자") + }) + @PostMapping("/signup") + public ResponseEntity> signupAndLogin( + @Valid @RequestBody AuthSignupRequest request) { + SignupUserResponse authResult = signUpUsecase.register( + request.toUserSignupCommand() + ); + return ResponseEntity.created(URI.create("/api/v1/user" + authResult.userId())) + .body(SuccessResponse.of( + UserHttpResponseCode.SIGNUP_SUCCESS, + authResult) + ); + } + + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰") + }) + @PostMapping("/refresh") + public ResponseEntity> refreshToken( + @RequestHeader("Authorization") String refreshToken) { + String token = refreshToken.replace("Bearer ", ""); + TokenPair newTokens = tokenRefreshService.refreshAccessToken(token); + return ResponseEntity.ok().body( + SuccessResponse.of( + UserHttpResponseCode.REFRESH_SUCCESS, + newTokens + ) + ); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/AppleLoginRequest.java b/src/main/java/org/runimo/runimo/auth/controller/request/AppleLoginRequest.java new file mode 100644 index 00000000..d6871a9e --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/request/AppleLoginRequest.java @@ -0,0 +1,13 @@ +package org.runimo.runimo.auth.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "애플 로그인 요청") +public record AppleLoginRequest( + @Schema(description = "인증 코드") + @NotBlank String authCode, + @Schema(description = "Code Verifier") + @NotBlank String codeVerifier +) { +} diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java new file mode 100644 index 00000000..395a1744 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java @@ -0,0 +1,22 @@ +package org.runimo.runimo.auth.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.URL; +import org.runimo.runimo.auth.service.dtos.UserSignupCommand; + +@Schema(description = "사용자 회원가입 요청 DTO") +public record AuthSignupRequest( + @Schema(description = "회원가입용 임시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") + @NotBlank String registerToken, + + @Schema(description = "사용자 닉네임", example = "RunimoUser") + @NotBlank String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.jpg") + @URL String imgUrl +) { + public UserSignupCommand toUserSignupCommand() { + return new UserSignupCommand(registerToken, nickname, imgUrl); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/KakaoLoginRequest.java b/src/main/java/org/runimo/runimo/auth/controller/request/KakaoLoginRequest.java new file mode 100644 index 00000000..af332a21 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/request/KakaoLoginRequest.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.auth.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "카카오 로그인 요청") +public record KakaoLoginRequest( + @Schema(description = "카카오 oidc토큰") + String oidcToken +) { +} diff --git a/src/main/java/org/runimo/runimo/user/exceptions/SignUpException.java b/src/main/java/org/runimo/runimo/auth/exceptions/SignUpException.java similarity index 89% rename from src/main/java/org/runimo/runimo/user/exceptions/SignUpException.java rename to src/main/java/org/runimo/runimo/auth/exceptions/SignUpException.java index 3eaa01f9..9d2d7c27 100644 --- a/src/main/java/org/runimo/runimo/user/exceptions/SignUpException.java +++ b/src/main/java/org/runimo/runimo/auth/exceptions/SignUpException.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.exceptions; +package org.runimo.runimo.auth.exceptions; import org.runimo.runimo.exceptions.BusinessException; import org.runimo.runimo.exceptions.code.CustomResponseCode; diff --git a/src/main/java/org/runimo/runimo/auth/exceptions/UnRegisteredUserException.java b/src/main/java/org/runimo/runimo/auth/exceptions/UnRegisteredUserException.java new file mode 100644 index 00000000..4de984f7 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/exceptions/UnRegisteredUserException.java @@ -0,0 +1,27 @@ +package org.runimo.runimo.auth.exceptions; + +import org.runimo.runimo.exceptions.BusinessException; +import org.runimo.runimo.exceptions.code.CustomResponseCode; + + +public class UnRegisteredUserException extends BusinessException { + private final String temporalRegisterToken; + + protected UnRegisteredUserException(CustomResponseCode errorCode, String temporalRegisterToken) { + super(errorCode); + this.temporalRegisterToken = temporalRegisterToken; + } + + protected UnRegisteredUserException(CustomResponseCode errorCode, String logMessage, String temporalRegisterToken) { + super(errorCode, logMessage); + this.temporalRegisterToken = temporalRegisterToken; + } + + public static UnRegisteredUserException of(CustomResponseCode errorCode, String token) { + return new UnRegisteredUserException(errorCode, token); + } + + public String getTemporalRegisterToken() { + return temporalRegisterToken; + } +} diff --git a/src/main/java/org/runimo/runimo/user/exceptions/UserJwtException.java b/src/main/java/org/runimo/runimo/auth/exceptions/UserJwtException.java similarity index 67% rename from src/main/java/org/runimo/runimo/user/exceptions/UserJwtException.java rename to src/main/java/org/runimo/runimo/auth/exceptions/UserJwtException.java index 1d7784ee..4fa979c8 100644 --- a/src/main/java/org/runimo/runimo/user/exceptions/UserJwtException.java +++ b/src/main/java/org/runimo/runimo/auth/exceptions/UserJwtException.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.exceptions; +package org.runimo.runimo.auth.exceptions; import org.runimo.runimo.exceptions.BusinessException; import org.runimo.runimo.exceptions.code.CustomResponseCode; @@ -11,4 +11,8 @@ protected UserJwtException(CustomResponseCode errorCode) { public UserJwtException(CustomResponseCode errorCode, String logMessage) { super(errorCode, logMessage); } + + public static UserJwtException of(CustomResponseCode errorCode) { + return new UserJwtException(errorCode, errorCode.getLogMessage()); + } } diff --git a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java index 1d2c4c53..3f4b1bd6 100644 --- a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java +++ b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java @@ -63,7 +63,7 @@ private String extractToken(HttpServletRequest request) { private boolean processToken(String jwtToken, HttpServletResponse response) throws IOException { try { - String userId = jwtResolver.getUserIdFromAccessToken(jwtToken); + String userId = jwtResolver.getUserIdFromJwtToken(jwtToken); Authentication authentication = new UsernamePasswordAuthenticationToken( userId, null, diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java index 2915615f..004fe37e 100644 --- a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java @@ -4,6 +4,9 @@ import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.enums.UserHttpResponseCode; public class JwtResolver { @@ -14,12 +17,27 @@ public JwtResolver(String jwtSecret) { this.jwtSecret = jwtSecret; } - public DecodedJWT verifyAccessToken(String token) throws JWTVerificationException { + public DecodedJWT verifyJwtToken(String token) throws JWTVerificationException { return JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(ISSUER).build().verify(token); } - public String getUserIdFromAccessToken(String token) throws JWTVerificationException { - DecodedJWT jwt = verifyAccessToken(token); + public String getUserIdFromJwtToken(String token) throws JWTVerificationException { + DecodedJWT jwt = verifyJwtToken(token); return jwt.getSubject(); } + + public RegisterTokenPayload getRegisterTokenPayload(String token) throws JWTVerificationException { + try { + DecodedJWT decodedJWT = verifyJwtToken(token); + String providerId = decodedJWT.getClaim("provider_id").asString(); + SocialProvider socialProvider = SocialProvider.valueOf( + decodedJWT.getClaim("provider").asString() + ); + return new RegisterTokenPayload(providerId, socialProvider); + } catch (JWTVerificationException e) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); + } + } + + } diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java index 3c650b00..e945202c 100644 --- a/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java @@ -2,10 +2,12 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import org.runimo.runimo.auth.service.dtos.TokenPair; +import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; -import org.runimo.runimo.user.service.dtos.TokenPair; import java.util.Date; +import java.util.UUID; public class JwtTokenFactory { @@ -14,11 +16,13 @@ public class JwtTokenFactory { private final String jwtSecret; private final long jwtExpiration; private final long jwtRefreshExpiration; + private final long tempJwtExpiration; - public JwtTokenFactory(String jwtSecret, long jwtExpiration, long jwtRefreshExpiration) { + public JwtTokenFactory(String jwtSecret, long jwtExpiration, long jwtRefreshExpiration, long tempJwtExpiration) { this.jwtSecret = jwtSecret; this.jwtExpiration = jwtExpiration; this.jwtRefreshExpiration = jwtRefreshExpiration; + this.tempJwtExpiration = tempJwtExpiration; } public String generateAccessToken(String userPublicId) { @@ -46,6 +50,20 @@ public String generateRefreshToken(String userPublicId) { .sign(Algorithm.HMAC256(jwtSecret)); } + public String generateRegisterTemporalToken(String providerId, SocialProvider socialProvider) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + tempJwtExpiration); + return JWT.create() + .withSubject(UUID.randomUUID().toString()) + .withIssuedAt(now) + .withExpiresAt(expiryDate) + .withIssuer(ISSUER) + .withClaim("provider_id", providerId) + .withClaim("provider", socialProvider.name()) + .withClaim("tokenType", "register") + .sign(Algorithm.HMAC256(jwtSecret)); + } + public TokenPair generateTokenPair(User user) { String accessToken = generateAccessToken(user.getPublicId()); String refreshToken = generateRefreshToken(user.getPublicId()); diff --git a/src/main/java/org/runimo/runimo/auth/jwt/RegisterTokenPayload.java b/src/main/java/org/runimo/runimo/auth/jwt/RegisterTokenPayload.java new file mode 100644 index 00000000..bffcc1ce --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/jwt/RegisterTokenPayload.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.auth.jwt; + +import org.runimo.runimo.user.domain.SocialProvider; + +public record RegisterTokenPayload( + String providerId, + SocialProvider socialProvider +) { +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java deleted file mode 100644 index ecf4d153..00000000 --- a/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.runimo.runimo.auth.repository; - -import lombok.RequiredArgsConstructor; -import org.runimo.runimo.auth.jwt.TokenStatus; -import org.runimo.runimo.common.cache.CacheEntry; -import org.runimo.runimo.common.cache.InMemoryCache; -import org.runimo.runimo.user.domain.SocialProvider; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.Optional; - -@Component -@RequiredArgsConstructor -public class InMemoryOAuthTokenRepository implements OAuthTokenRepository { - - private final InMemoryCache tokenCache; - - public void storeNonce( - SocialProvider provider, String sub, String nonce, TokenStatus status, Duration ttl) { - String key = generateKey(provider, sub, nonce); - tokenCache.put(key, status, ttl); - } - - public Optional getNonceStatus(SocialProvider provider, String sub, String nonce) { - String key = generateKey(provider, sub, nonce); - return tokenCache.get(key); - } - - public void updateNonceStatus( - SocialProvider provider, String sub, String nonce, TokenStatus status) { - String key = generateKey(provider, sub, nonce); - CacheEntry entry = - tokenCache - .getEntry(key) - .orElseThrow(() -> new IllegalStateException("Token not found for key: " + key)); - - tokenCache.remove(key); - - Duration remainingTtl = Duration.between(java.time.Instant.now(), entry.expiresAt()); - tokenCache.put(key, status, remainingTtl); - } - - private String generateKey(SocialProvider provider, String sub, String nonce) { - return String.format("auth:%s:%s:%s", provider.name(), sub, nonce); - } -} diff --git a/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java deleted file mode 100644 index 7b2e27c9..00000000 --- a/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.runimo.runimo.auth.repository; - -import org.runimo.runimo.auth.jwt.TokenStatus; -import org.runimo.runimo.user.domain.SocialProvider; - -import java.time.Duration; -import java.util.Optional; - -public interface OAuthTokenRepository { - - void storeNonce( - SocialProvider socialProvider, String sub, String nonce, - TokenStatus status, Duration ttl - ); - - Optional getNonceStatus(SocialProvider socialProvider, String sub, String nonce); - - void updateNonceStatus(SocialProvider socialProvider, String sub, String nonce, TokenStatus status); -} diff --git a/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java b/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java deleted file mode 100644 index 576c26fd..00000000 --- a/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.runimo.runimo.auth.service; - -import com.auth0.jwt.interfaces.DecodedJWT; -import lombok.RequiredArgsConstructor; -import org.runimo.runimo.auth.jwt.TokenStatus; -import org.runimo.runimo.auth.repository.OAuthTokenRepository; -import org.runimo.runimo.user.domain.SocialProvider; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.time.Instant; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class OidcNonceService { - - private static final String NONCE_CLAIM_KEY = "nonce"; - private final OAuthTokenRepository oAuthTokenRepository; - - public void checkNonceAndSave(final SocialProvider provider, final DecodedJWT decodedJWT) { - Optional existingOidcTokenEntry = oAuthTokenRepository.getNonceStatus( - provider, - decodedJWT.getSubject(), - decodedJWT.getClaim(NONCE_CLAIM_KEY).asString() - ); - validateEntryExistsAndUnUsed(existingOidcTokenEntry); - storeNonceWithTTL(provider, decodedJWT, getTTLForNonce(decodedJWT)); - } - - private Duration getTTLForNonce(DecodedJWT decodedJWT) { - return Duration.between(Instant.now(), decodedJWT.getExpiresAtAsInstant()); - } - - /** - * Nonce가 없거나 이미 사용되었다면 탈취의 위험이 있으므로 에러를 반환 - */ - private void validateEntryExistsAndUnUsed(Optional status) { - if (status.isEmpty()) { - throw new IllegalStateException("nonce is not found"); - } - if (status.get() == TokenStatus.USED) { - throw new IllegalStateException("nonce is already used"); - } - } - - private void storeNonceWithTTL(final SocialProvider provider, final DecodedJWT decodedJWT, final Duration ttl) { - oAuthTokenRepository.storeNonce( - provider, - decodedJWT.getSubject(), - String.valueOf(decodedJWT.getClaim(NONCE_CLAIM_KEY)), - TokenStatus.PENDING, - ttl - ); - } - - public void useNonce(DecodedJWT token, SocialProvider provider) { - oAuthTokenRepository.updateNonceStatus(provider, token.getSubject(), String.valueOf(token.getClaim(NONCE_CLAIM_KEY)), TokenStatus.USED); - } -} diff --git a/src/main/java/org/runimo/runimo/auth/service/OidcService.java b/src/main/java/org/runimo/runimo/auth/service/OidcService.java index adaed8d4..9b72151b 100644 --- a/src/main/java/org/runimo/runimo/auth/service/OidcService.java +++ b/src/main/java/org/runimo/runimo/auth/service/OidcService.java @@ -1,24 +1,23 @@ package org.runimo.runimo.auth.service; -import com.auth0.jwt.interfaces.DecodedJWT; import lombok.RequiredArgsConstructor; -import org.runimo.runimo.auth.verifier.KakaoTokenVerifier; -import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.auth.service.apple.AppleLoginHandler; +import org.runimo.runimo.auth.service.dtos.AuthResponse; +import org.runimo.runimo.auth.service.kakao.KakaoLoginHandler; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class OidcService { - private final KakaoTokenVerifier verifier; + private final AppleLoginHandler appleLoginHandler; + private final KakaoLoginHandler kakaoLoginHandler; - public String validateOidcTokenAndGetProviderId(final DecodedJWT token, final SocialProvider provider) { - DecodedJWT verifyResult; - // APPLE 로그인 추가 예정. - switch (provider) { - case KAKAO -> verifyResult = verifier.verifyToken(token); - default -> throw new IllegalStateException("not supported provider"); - } - return verifyResult.getSubject(); + public AuthResponse kakaoLogin(String idToken) { + return kakaoLoginHandler.validateAndLogin(idToken); + } + + public AuthResponse appleLogin(String authCode, String codeVerifier) { + return appleLoginHandler.validateAndLogin(authCode, codeVerifier); } } diff --git a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecase.java b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecase.java new file mode 100644 index 00000000..4dc10361 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.auth.service; + +import org.runimo.runimo.auth.service.dtos.SignupUserResponse; +import org.runimo.runimo.auth.service.dtos.UserSignupCommand; + +public interface SignUpUsecase { + SignupUserResponse register(UserSignupCommand command); +} diff --git a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java new file mode 100644 index 00000000..aa78a4a5 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java @@ -0,0 +1,33 @@ +package org.runimo.runimo.auth.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.jwt.RegisterTokenPayload; +import org.runimo.runimo.auth.service.dtos.SignupUserResponse; +import org.runimo.runimo.auth.service.dtos.UserSignupCommand; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.service.dtos.UserRegisterCommand; +import org.runimo.runimo.user.service.UserRegisterService; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SignUpUsecaseImpl implements SignUpUsecase { + + private final UserRegisterService userRegisterService; + private final JwtResolver jwtResolver; + private final JwtTokenFactory jwtTokenFactory; + + @Override + public SignupUserResponse register(UserSignupCommand command) { + RegisterTokenPayload payload = jwtResolver.getRegisterTokenPayload(command.registerToken()); + User savedUser = userRegisterService.registerUser(new UserRegisterCommand( + command.nickname(), + command.imgUrl(), + payload.providerId(), + payload.socialProvider()) + ); + return new SignupUserResponse(savedUser, jwtTokenFactory.generateTokenPair(savedUser)); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java new file mode 100644 index 00000000..8bee1c0f --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java @@ -0,0 +1,46 @@ +package org.runimo.runimo.auth.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.dtos.TokenPair; +import org.runimo.runimo.common.cache.InMemoryCache; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class TokenRefreshService { + + private final JwtResolver jwtResolver; + private final InMemoryCache refreshTokenCache; + private final JwtTokenFactory jwtTokenFactory; + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpiry; + + public TokenPair refreshAccessToken(String refreshToken) { + String userId; + try { + jwtResolver.verifyJwtToken(refreshToken); + userId = jwtResolver.getUserIdFromJwtToken(refreshToken); + } catch (Exception e) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL); + } + + String storedToken = refreshTokenCache.get(userId).orElse(null); + if (storedToken == null || !storedToken.equals(refreshToken)) { + throw new IllegalArgumentException("Refresh token mismatch"); + } + + String newAccessToken = jwtTokenFactory.generateAccessToken(userId); + String newRefreshToken = jwtTokenFactory.generateRefreshToken(userId); + + // 갱신한 리프레시 토큰 저장 (기존 토큰 갱신) + refreshTokenCache.put(userId, newRefreshToken, Duration.ofMillis(refreshTokenExpiry)); + return new TokenPair(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java b/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java new file mode 100644 index 00000000..0f1311f7 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/apple/AppleLoginHandler.java @@ -0,0 +1,49 @@ +package org.runimo.runimo.auth.service.apple; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.dtos.AuthResponse; +import org.runimo.runimo.auth.service.dtos.TokenPair; +import org.runimo.runimo.auth.service.kakao.AppleUserInfo; +import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.repository.OAuthInfoRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class AppleLoginHandler { + + private final AppleTokenVerifier appleTokenVerifier; + private final JwtTokenFactory jwtTokenFactory; + private final OAuthInfoRepository oAuthInfoRepository; + + @Transactional(readOnly = true) + public AuthResponse validateAndLogin(final String authCode, final String verifier) { + String rawToken = appleTokenVerifier.getAccessTokenFromAuthCode(authCode, verifier); + DecodedJWT decodedJWT; + try { + decodedJWT = JWT.decode(rawToken); + } catch (JWTDecodeException e) { + throw UserJwtException.of(UserHttpResponseCode.LOGIN_FAIL_INVALID); + } + AppleUserInfo userInfo = appleTokenVerifier.verifyToken(decodedJWT); + OAuthInfo savedUser = oAuthInfoRepository.findByProviderAndProviderId( + SocialProvider.APPLE, + userInfo.getProviderId()) + .orElseThrow(() -> + UnRegisteredUserException.of( + UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN, + jwtTokenFactory.generateRegisterTemporalToken(userInfo.getProviderId(), SocialProvider.APPLE)) + ); + TokenPair tokenPair = jwtTokenFactory.generateTokenPair(savedUser.getUser()); + return new AuthResponse(savedUser.getUser(), tokenPair); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java new file mode 100644 index 00000000..47a3466a --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/apple/AppleTokenVerifier.java @@ -0,0 +1,194 @@ +package org.runimo.runimo.auth.service.apple; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.kakao.AppleUserInfo; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.ECKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleTokenVerifier { + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map publicKeys = new HashMap<>(); + private final JwtTokenFactory jwtTokenFactory; + + @Value("${apple.client-id}") + private String clientId; + + @Value("${apple.redirect-uri}") + private String redirectUri; + + @Value("${apple.team-id}") + private String appleTeamId; + + @Value("${apple.key-id}") + private String appleKeyId; + + @Value("${apple.client-secret}") + private String applePrivateKey; + + @Scheduled(fixedRate = 3600000) + public void refreshPublicKeys() { + String jwksUrl = "https://appleid.apple.com/auth/keys"; + String jwksResponse = restTemplate.getForObject(jwksUrl, String.class); + try { + JsonNode jwks = objectMapper.readTree(jwksResponse); + for (JsonNode key : jwks.get("keys")) { + String kid = key.get("kid").asText(); + String n = key.get("n").asText(); + String e = key.get("e").asText(); + + RSAPublicKey publicKey = createPublicKey(n, e); + publicKeys.put(kid, publicKey); + } + } catch (Exception e) { + throw new RuntimeException("Failed to refresh Apple public keys", e); + } + } + + private RSAPublicKey createPublicKey(String modulusBase64, String exponentBase64) + throws Exception { + byte[] modulusBytes = Base64.getUrlDecoder().decode(modulusBase64); + byte[] exponentBytes = Base64.getUrlDecoder().decode(exponentBase64); + + BigInteger modulus = new BigInteger(1, modulusBytes); + BigInteger exponent = new BigInteger(1, exponentBytes); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) factory.generatePublic(spec); + } + + public String getAccessTokenFromAuthCode(String authCode, String codeVerifier) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("client_id", clientId); + map.add("client_secret", generateAppleClientSecret()); + map.add("code", authCode); + map.add("grant_type", "authorization_code"); + map.add("redirect_uri", redirectUri); + map.add("code_verifier", codeVerifier); + + HttpEntity> request = new HttpEntity<>(map, headers); + + ResponseEntity response = restTemplate.postForEntity( + "https://appleid.apple.com/auth/token", request, String.class); + + try { + JsonNode node = objectMapper.readTree(response.getBody()); + return node.get("id_token").asText(); + } catch (Exception e) { + log.error("Failed to verify Apple access token", e); + throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); + } + } + + public AppleUserInfo verifyToken(DecodedJWT token) { + try { + RSAPublicKey publicKey = publicKeys.get(token.getKeyId()); + if (publicKey == null) { + throw new JWTVerificationException("Unable to find appropriate Apple key"); + } + + Algorithm algorithm = Algorithm.RSA256(publicKey, null); + + DecodedJWT decodedJWT = JWT.require(algorithm) + .withIssuer("https://appleid.apple.com") + .withAudience(clientId) + .build() + .verify(token); + + String subject = decodedJWT.getSubject(); + String name = decodedJWT.getClaim("name").asString(); + return new AppleUserInfo(subject, name); + } catch (JWTVerificationException exception) { + log.error("Failed to verify Apple access token", exception); + throw new UserJwtException(UserHttpResponseCode.JWT_TOKEN_BROKEN, exception.getMessage()); + } + } + + /** + * Apple의 권장 방식대로 JWT 형태의 클라이언트 시크릿을 동적으로 생성 + * 해당 시크릿은 6개월 동안 유효함 + */ + private String generateAppleClientSecret() { + try { + // 현재 시간 및 6개월 후 만료 시간 설정 + long nowMillis = System.currentTimeMillis(); + long expMillis = nowMillis + 180L * 24 * 60 * 60 * 1000; // 180일 + + // 개인 키 포맷팅 및 디코딩 + String formattedKey = "-----BEGIN PRIVATE KEY-----\n" + applePrivateKey.replace("\\\\n", "\n") + .trim() + "\n-----END PRIVATE KEY-----"; + + byte[] decodedKey = Base64.getDecoder().decode( + formattedKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s", "") + ); + + // 개인 키 생성 + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKey); + PrivateKey privateKey = keyFactory.generatePrivate(keySpec); + + // JWT 클라이언트 시크릿 생성 + return JWT.create() + .withHeader(Map.of("kid", appleKeyId)) + .withIssuer(appleTeamId) + .withIssuedAt(new Date(nowMillis)) + .withExpiresAt(new Date(expMillis)) + .withAudience("https://appleid.apple.com") + .withSubject(clientId) + .sign(Algorithm.ECDSA256((ECKey) privateKey)); + + } catch (Exception e) { + log.error("Failed to verify Apple access token", e); + throw new RuntimeException("Failed to generate Apple client secret", e); + } + } + + /** + * 애플 토큰 검증을 위한 공개키를 의존성 주입 이후에 조회하여 캐싱하는 메소드 + */ + @PostConstruct + public void initAppleTokenVerifier() { + refreshPublicKeys(); + } +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/auth/service/apple/KakaoUserInfo.java b/src/main/java/org/runimo/runimo/auth/service/apple/KakaoUserInfo.java new file mode 100644 index 00000000..613a2d64 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/apple/KakaoUserInfo.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.auth.service.apple; + +import lombok.Getter; + +@Getter +public class KakaoUserInfo { + private final String providerId; + + public KakaoUserInfo(String providerId) { + this.providerId = providerId; + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java b/src/main/java/org/runimo/runimo/auth/service/dtos/AuthResponse.java similarity index 88% rename from src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java rename to src/main/java/org/runimo/runimo/auth/service/dtos/AuthResponse.java index 632b5ab0..d70d77eb 100644 --- a/src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java +++ b/src/main/java/org/runimo/runimo/auth/service/dtos/AuthResponse.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.service.dtos; +package org.runimo.runimo.auth.service.dtos; import org.runimo.runimo.user.domain.User; diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/SignupUserResponse.java b/src/main/java/org/runimo/runimo/auth/service/dtos/SignupUserResponse.java similarity index 88% rename from src/main/java/org/runimo/runimo/user/service/dtos/SignupUserResponse.java rename to src/main/java/org/runimo/runimo/auth/service/dtos/SignupUserResponse.java index 353bd43c..65301bfe 100644 --- a/src/main/java/org/runimo/runimo/user/service/dtos/SignupUserResponse.java +++ b/src/main/java/org/runimo/runimo/auth/service/dtos/SignupUserResponse.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.service.dtos; +package org.runimo.runimo.auth.service.dtos; import org.runimo.runimo.user.domain.User; diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java b/src/main/java/org/runimo/runimo/auth/service/dtos/TokenPair.java similarity index 61% rename from src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java rename to src/main/java/org/runimo/runimo/auth/service/dtos/TokenPair.java index 90a62aa1..30775a78 100644 --- a/src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java +++ b/src/main/java/org/runimo/runimo/auth/service/dtos/TokenPair.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.service.dtos; +package org.runimo.runimo.auth.service.dtos; public record TokenPair(String accessToken, String refreshToken) { } diff --git a/src/main/java/org/runimo/runimo/auth/service/dtos/UserSignupCommand.java b/src/main/java/org/runimo/runimo/auth/service/dtos/UserSignupCommand.java new file mode 100644 index 00000000..68bd94b0 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/dtos/UserSignupCommand.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.auth.service.dtos; + + +public record UserSignupCommand( + String registerToken, + String nickname, + String imgUrl +) { +} diff --git a/src/main/java/org/runimo/runimo/auth/service/kakao/AppleUserInfo.java b/src/main/java/org/runimo/runimo/auth/service/kakao/AppleUserInfo.java new file mode 100644 index 00000000..eb770a21 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/kakao/AppleUserInfo.java @@ -0,0 +1,14 @@ +package org.runimo.runimo.auth.service.kakao; + +import lombok.Getter; + +@Getter +public class AppleUserInfo { + private final String providerId; + private final String name; + + public AppleUserInfo(String providerId, String name) { + this.providerId = providerId; + this.name = name; + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/kakao/KakaoLoginHandler.java b/src/main/java/org/runimo/runimo/auth/service/kakao/KakaoLoginHandler.java new file mode 100644 index 00000000..750ced2c --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/kakao/KakaoLoginHandler.java @@ -0,0 +1,44 @@ + package org.runimo.runimo.auth.service.kakao; + + import com.auth0.jwt.JWT; + import com.auth0.jwt.exceptions.JWTDecodeException; + import com.auth0.jwt.interfaces.DecodedJWT; + import lombok.RequiredArgsConstructor; + import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; + import org.runimo.runimo.auth.exceptions.UserJwtException; + import org.runimo.runimo.auth.jwt.JwtTokenFactory; + import org.runimo.runimo.auth.service.apple.KakaoUserInfo; + import org.runimo.runimo.auth.service.dtos.AuthResponse; + import org.runimo.runimo.auth.service.dtos.TokenPair; + import org.runimo.runimo.user.domain.OAuthInfo; + import org.runimo.runimo.user.domain.SocialProvider; + import org.runimo.runimo.user.enums.UserHttpResponseCode; + import org.runimo.runimo.user.repository.OAuthInfoRepository; + import org.springframework.stereotype.Component; + import org.springframework.transaction.annotation.Transactional; + + @Component + @RequiredArgsConstructor + public class KakaoLoginHandler { + + private final KakaoTokenVerifier kakaoTokenVerifier; + private final OAuthInfoRepository oAuthInfoRepository; + private final JwtTokenFactory jwtTokenFactory; + + @Transactional(readOnly = true) + public AuthResponse validateAndLogin(final String rawToken) { + DecodedJWT token; + try { + token = JWT.decode(rawToken); + } catch (JWTDecodeException e) { + throw UserJwtException.of(UserHttpResponseCode.LOGIN_FAIL_INVALID); + } + KakaoUserInfo kakaoUserInfo = kakaoTokenVerifier.verifyToken(token); + OAuthInfo oAuthInfo = oAuthInfoRepository.findByProviderAndProviderId(SocialProvider.KAKAO, kakaoUserInfo.getProviderId()) + .orElseThrow(() -> UnRegisteredUserException.of( + UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN, + jwtTokenFactory.generateRegisterTemporalToken(kakaoUserInfo.getProviderId(), SocialProvider.KAKAO))); + TokenPair tokenPair = jwtTokenFactory.generateTokenPair(oAuthInfo.getUser()); + return new AuthResponse(oAuthInfo.getUser(), tokenPair); + } + } diff --git a/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/service/kakao/KakaoTokenVerifier.java similarity index 86% rename from src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java rename to src/main/java/org/runimo/runimo/auth/service/kakao/KakaoTokenVerifier.java index f5d0c97b..720db483 100644 --- a/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java +++ b/src/main/java/org/runimo/runimo/auth/service/kakao/KakaoTokenVerifier.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.auth.verifier; +package org.runimo.runimo.auth.service.kakao; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; @@ -8,9 +8,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; -import org.runimo.runimo.auth.repository.OAuthTokenRepository; +import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.service.apple.KakaoUserInfo; import org.runimo.runimo.user.enums.UserHttpResponseCode; -import org.runimo.runimo.user.exceptions.UserJwtException; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -24,11 +25,11 @@ import java.util.HashMap; import java.util.Map; +@Slf4j @Service @RequiredArgsConstructor -public class KakaoTokenVerifier implements OidcTokenVerifier { +public class KakaoTokenVerifier { - private final OAuthTokenRepository oAuthTokenRepository; private final RestTemplate restTemplate = new RestTemplate(); private final ObjectMapper objectMapper = new ObjectMapper(); private final Map publicKeys = new HashMap<>(); @@ -51,6 +52,7 @@ public void refreshPublicKeys() { publicKeys.put(kid, publicKey); } } catch (Exception e) { + log.error("Failed to refresh kakao public keys", e); throw new RuntimeException("Failed to refresh public keys", e); } } @@ -68,8 +70,7 @@ private RSAPublicKey createPublicKey(String modulusBase64, String exponentBase64 return (RSAPublicKey) factory.generatePublic(spec); } - @Override - public DecodedJWT verifyToken(DecodedJWT token) { + public KakaoUserInfo verifyToken(DecodedJWT token) { try { String nonce = token.getClaim("nonce").asString(); @@ -84,13 +85,14 @@ public DecodedJWT verifyToken(DecodedJWT token) { Algorithm algorithm = Algorithm.RSA256(publicKey, null); - return JWT.require(algorithm) + DecodedJWT decodedJWT = JWT.require(algorithm) .withIssuer("https://kauth.kakao.com") .withAudience(appKey) .build() .verify(token); + return new KakaoUserInfo(decodedJWT.getSubject()); } catch (JWTVerificationException exception) { - throw new UserJwtException(UserHttpResponseCode.JWT_TOKEN_BROKEN,exception.getMessage()); + throw new UserJwtException(UserHttpResponseCode.JWT_TOKEN_BROKEN, exception.getMessage()); } } diff --git a/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java deleted file mode 100644 index 04f2d8fa..00000000 --- a/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.runimo.runimo.auth.verifier; - -import com.auth0.jwt.interfaces.DecodedJWT; - -public interface OidcTokenVerifier { - DecodedJWT verifyToken(DecodedJWT token); - -} diff --git a/src/main/java/org/runimo/runimo/common/GlobalConsts.java b/src/main/java/org/runimo/runimo/common/GlobalConsts.java index 7c8202a7..0332f7f8 100644 --- a/src/main/java/org/runimo/runimo/common/GlobalConsts.java +++ b/src/main/java/org/runimo/runimo/common/GlobalConsts.java @@ -12,7 +12,7 @@ public final class GlobalConsts { public static final String TIME_ZONE_ID = "Asia/Seoul"; public static final Set WHITE_LIST_ENDPOINTS = Set.of( "/api/v1/test/auth", - "/api/v1/users/auth", + "/api/v1/auth", "/swagger-ui", "/v3/api-docs" ); diff --git a/src/main/java/org/runimo/runimo/config/CacheConfig.java b/src/main/java/org/runimo/runimo/config/CacheConfig.java index db17e8be..8873d743 100644 --- a/src/main/java/org/runimo/runimo/config/CacheConfig.java +++ b/src/main/java/org/runimo/runimo/config/CacheConfig.java @@ -1,7 +1,6 @@ package org.runimo.runimo.config; import com.sun.security.auth.UserPrincipal; -import org.runimo.runimo.auth.jwt.TokenStatus; import org.runimo.runimo.common.cache.InMemoryCache; import org.runimo.runimo.common.cache.SpringInMemoryCache; import org.springframework.beans.factory.annotation.Value; @@ -33,7 +32,7 @@ public TaskScheduler cacheCleanupScheduler() { } @Bean - public InMemoryCache tokenStatusCache(TaskScheduler cacheCleanupScheduler) { + public InMemoryCache refreshTokenCache(TaskScheduler cacheCleanupScheduler) { return new SpringInMemoryCache<>( cacheCleanupScheduler, Duration.ofSeconds(cleanupIntervalSeconds) diff --git a/src/main/java/org/runimo/runimo/config/JwtConfig.java b/src/main/java/org/runimo/runimo/config/JwtConfig.java index 059beb56..870f02de 100644 --- a/src/main/java/org/runimo/runimo/config/JwtConfig.java +++ b/src/main/java/org/runimo/runimo/config/JwtConfig.java @@ -17,10 +17,12 @@ public class JwtConfig { private long jwtExpiration; @Value("${jwt.refresh.expiration}") private long jwtRefreshExpiration; + @Value("${jwt.temp.expiration}") + private long tempJwtExpiration; @Bean public JwtTokenFactory jwtTokenFactory() { - return new JwtTokenFactory(jwtSecret, jwtExpiration, jwtRefreshExpiration); + return new JwtTokenFactory(jwtSecret, jwtExpiration, jwtRefreshExpiration, tempJwtExpiration); } @Bean diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java index 281370f0..5e2ed6b3 100644 --- a/src/main/java/org/runimo/runimo/config/SecurityConfig.java +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/users/auth/**").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -47,7 +47,7 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/users/auth/**").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ); diff --git a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java index c01ff033..22b58901 100644 --- a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java @@ -1,12 +1,14 @@ package org.runimo.runimo.exceptions; +import com.auth0.jwt.exceptions.JWTDecodeException; import jakarta.persistence.LockTimeoutException; import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; import org.runimo.runimo.common.response.ErrorResponse; import org.runimo.runimo.hatch.exception.HatchException; import org.runimo.runimo.runimo.exception.RunimoException; -import org.runimo.runimo.user.exceptions.SignUpException; -import org.runimo.runimo.user.exceptions.UserJwtException; +import org.runimo.runimo.auth.exceptions.SignUpException; +import org.runimo.runimo.auth.exceptions.UserJwtException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -37,12 +39,24 @@ public ResponseEntity handleHatchException(HatchException e) { return ResponseEntity.status(e.getHttpStatusCode()).body(ErrorResponse.of(e.getErrorCode())); } + @ExceptionHandler(UserJwtException.class) public ResponseEntity handleUserJwtException(UserJwtException e) { log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); return ResponseEntity.status(e.getHttpStatusCode()).body(ErrorResponse.of(e.getErrorCode())); } + @ExceptionHandler(UnRegisteredUserException.class) + public ResponseEntity handleUnRegisteredUserException(UnRegisteredUserException e) { + log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); + return ResponseEntity.status(e.getHttpStatusCode()).body( + new RegisterErrorResponse( + e.getErrorCode().getCode(), + e.getMessage(), + e.getTemporalRegisterToken() + )); + } + @ExceptionHandler(SignUpException.class) public ResponseEntity handleSignUpException(SignUpException e) { log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); @@ -115,4 +129,11 @@ public ResponseEntity handleBusinessException(BusinessException e log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); return ResponseEntity.badRequest().body(ErrorResponse.of(e.getErrorCode())); } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException e) { + log.error("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + ErrorResponse.of("internal server error", e.getMessage())); + } } diff --git a/src/main/java/org/runimo/runimo/exceptions/RegisterErrorResponse.java b/src/main/java/org/runimo/runimo/exceptions/RegisterErrorResponse.java new file mode 100644 index 00000000..64cc79f8 --- /dev/null +++ b/src/main/java/org/runimo/runimo/exceptions/RegisterErrorResponse.java @@ -0,0 +1,17 @@ +package org.runimo.runimo.exceptions; + +import lombok.Getter; + +@Getter +public class RegisterErrorResponse { + private boolean success = false; + private String errorCode; + private String message; + private String temporalRegisterToken; + + public RegisterErrorResponse(String errorCode, String message, String temporalRegisterToken) { + this.errorCode = errorCode; + this.message = message; + this.temporalRegisterToken = temporalRegisterToken; + } +} diff --git a/src/main/java/org/runimo/runimo/user/controller/UserController.java b/src/main/java/org/runimo/runimo/user/controller/UserController.java deleted file mode 100644 index f914d480..00000000 --- a/src/main/java/org/runimo/runimo/user/controller/UserController.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.runimo.runimo.user.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.runimo.runimo.common.response.SuccessResponse; -import org.runimo.runimo.user.controller.request.AuthLoginRequest; -import org.runimo.runimo.user.controller.request.AuthSignupRequest; -import org.runimo.runimo.user.domain.SocialProvider; -import org.runimo.runimo.user.enums.UserHttpResponseCode; -import org.runimo.runimo.user.service.dtos.AuthResponse; -import org.runimo.runimo.user.service.dtos.SignupUserResponse; -import org.runimo.runimo.user.service.usecases.auth.UserOAuthUsecase; -import org.springframework.http.ResponseEntity; -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.RestController; - -import java.net.URI; - -@Tag(name = "USER", description = "사용자 관련 API") -@RestController -@RequestMapping("/api/v1/users") -@RequiredArgsConstructor -public class UserController { - - private final UserOAuthUsecase userOAuthUsecase; - - @Operation(summary = "사용자 로그인", description = "사용자가 OIDC 토큰을 사용하여 로그인합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), - @ApiResponse(responseCode = "401", description = "인증 실패"), - @ApiResponse(responseCode = "404", description = "가입 안함"), - }) - @PostMapping("/auth/login") - public ResponseEntity> login( - @Valid @RequestBody AuthLoginRequest request - ) { - AuthResponse res = userOAuthUsecase.validateAndLogin( - request.oidcToken(), - SocialProvider.valueOf(request.provider()) - ); - return ResponseEntity.ok().body( - SuccessResponse.of( - UserHttpResponseCode.LOGIN_SUCCESS, - res - )); - } - - @Operation(summary = "사용자 회원가입 및 로그인", description = "사용자가 OIDC 토큰을 사용하여 회원가입 후 로그인합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "회원가입 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), - @ApiResponse(responseCode = "409", description = "이미 존재하는 사용자") - }) - @PostMapping("/auth/signup") - public ResponseEntity> signupAndLogin( - @Valid @RequestBody AuthSignupRequest request) { - SignupUserResponse authResult = userOAuthUsecase.validateAndSignup( - request.toUserSignupCommand(), - request.oidcToken(), - SocialProvider.valueOf(request.provider()) - ); - return ResponseEntity.created(URI.create("/api/v1/user" + authResult.userId())) - .body(SuccessResponse.of( - UserHttpResponseCode.SIGNUP_SUCCESS, - authResult)); - } -} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java deleted file mode 100644 index 15842894..00000000 --- a/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.runimo.runimo.user.controller.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import org.runimo.runimo.common.EnumValid; -import org.runimo.runimo.user.domain.SocialProvider; - -@Schema(description = "사용자 로그인 요청 DTO") -public record AuthLoginRequest( - @Schema(description = "OIDC ID 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") - @NotBlank String oidcToken, - @Schema(description = "소셜 로그인 제공자 (APPLE, KAKAO)", example = "APPLE") - @NotBlank @EnumValid(enumClass = SocialProvider.class) String provider -) { -} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java deleted file mode 100644 index 3c870518..00000000 --- a/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.runimo.runimo.user.controller.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; -import org.runimo.runimo.common.EnumValid; -import org.runimo.runimo.user.domain.SocialProvider; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; - -@Schema(description = "사용자 회원가입 요청 DTO") -public record AuthSignupRequest( - - @Schema(description = "OIDC ID 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") - @NotBlank String oidcToken, - - @Schema(description = "소셜 로그인 제공자 (APPLE, KAKAO)", example = "APPLE") - @NotBlank @EnumValid(enumClass = SocialProvider.class) String provider, - - @Schema(description = "사용자 닉네임", example = "RunimoUser") - @NotBlank String nickname, - - @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.jpg") - @URL String imgUrl -) { - public UserSignupCommand toUserSignupCommand() { - return new UserSignupCommand(nickname, SocialProvider.valueOf(provider), imgUrl); - } -} diff --git a/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java b/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java index e5bfeb78..665d80cf 100644 --- a/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java +++ b/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java @@ -1,5 +1,5 @@ package org.runimo.runimo.user.domain; public enum SocialProvider { - KAKAO + APPLE, KAKAO } diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index 13427995..cfa22366 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -17,8 +17,11 @@ public enum UserHttpResponseCode implements CustomResponseCode { LOGIN_FAIL_NOT_SIGN_IN(HttpStatus.NOT_FOUND , "로그인 실패 - 회원가입하지 않은 사용자", "로그인 실패 - 회원가입하지 않은 사용자"), + LOGIN_FAIL_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT Decode실패"), SIGNIN_FAIL_ALREADY_EXIST(HttpStatus.CONFLICT, "로그인 실패 - 이미 존재하는 사용자", "로그인 실패 - 이미 존재하는 사용자"), - JWT_TOKEN_BROKEN(HttpStatus.BAD_REQUEST, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"),; + JWT_TOKEN_BROKEN(HttpStatus.BAD_REQUEST, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), + TOKEN_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 재발급 실패", "Refresh 토큰이 유효하지 않습니다."), + TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT 토큰 인증 실패"); private final HttpStatus code; private final String clientMessage; diff --git a/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java index 093b2efe..89ef2c56 100644 --- a/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java +++ b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java @@ -2,6 +2,7 @@ import org.runimo.runimo.user.domain.OAuthInfo; import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @@ -13,4 +14,6 @@ public interface OAuthInfoRepository extends JpaRepository { @Query("SELECT o FROM OAuthInfo o WHERE o.provider = :provider AND o.providerId = :providerId") Optional findByProviderAndProviderId(SocialProvider provider, String providerId); + + SocialProvider user(User user); } diff --git a/src/main/java/org/runimo/runimo/user/service/UserCreator.java b/src/main/java/org/runimo/runimo/user/service/UserCreator.java index 4f3fc640..41cc274b 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserCreator.java +++ b/src/main/java/org/runimo/runimo/user/service/UserCreator.java @@ -5,7 +5,7 @@ import org.runimo.runimo.user.repository.LovePointRepository; import org.runimo.runimo.user.repository.OAuthInfoRepository; import org.runimo.runimo.user.repository.UserRepository; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; +import org.runimo.runimo.user.service.dtos.UserCreateCommand; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +17,7 @@ public class UserCreator { private final LovePointRepository lovePointRepository; @Transactional - public User createUser(UserSignupCommand command) { + public User createUser(UserCreateCommand command) { User user = User.builder() .nickname(command.nickname()) .imgUrl(command.imgUrl()) diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserRegisterService.java b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java similarity index 55% rename from src/main/java/org/runimo/runimo/user/service/usecases/auth/UserRegisterService.java rename to src/main/java/org/runimo/runimo/user/service/UserRegisterService.java index a31d31bf..e1439920 100644 --- a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserRegisterService.java +++ b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java @@ -1,15 +1,14 @@ -package org.runimo.runimo.user.service.usecases.auth; +package org.runimo.runimo.user.service; import lombok.RequiredArgsConstructor; import org.runimo.runimo.rewards.service.eggs.EggGrantService; import org.runimo.runimo.user.domain.User; -import org.runimo.runimo.user.service.UserCreator; -import org.runimo.runimo.user.service.UserItemCreator; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; -import org.springframework.stereotype.Component; +import org.runimo.runimo.user.service.dtos.UserCreateCommand; +import org.runimo.runimo.user.service.dtos.UserRegisterCommand; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Component +@Service @RequiredArgsConstructor public class UserRegisterService { @@ -18,9 +17,9 @@ public class UserRegisterService { private final EggGrantService eggGrantService; @Transactional - public User register(UserSignupCommand command, String providerId) { - User savedUser = userCreator.createUser(command); - userCreator.createUserOAuthInfo(savedUser, command.provider(), providerId); + public User registerUser(UserRegisterCommand command) { + User savedUser = userCreator.createUser(new UserCreateCommand(command.nickname(), command.imgUrl())); + userCreator.createUserOAuthInfo(savedUser, command.socialProvider(), command.providerId()); userCreator.createLovePoint(savedUser.getId()); userItemCreator.createAll(savedUser.getId()); eggGrantService.grantGreetingEggToUser(savedUser); diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/UserCreateCommand.java b/src/main/java/org/runimo/runimo/user/service/dtos/UserCreateCommand.java new file mode 100644 index 00000000..4b935550 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/UserCreateCommand.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.user.service.dtos; + +public record UserCreateCommand( + String nickname, + String imgUrl +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java b/src/main/java/org/runimo/runimo/user/service/dtos/UserRegisterCommand.java similarity index 53% rename from src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java rename to src/main/java/org/runimo/runimo/user/service/dtos/UserRegisterCommand.java index 497634a4..44d1e384 100644 --- a/src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java +++ b/src/main/java/org/runimo/runimo/user/service/dtos/UserRegisterCommand.java @@ -2,9 +2,10 @@ import org.runimo.runimo.user.domain.SocialProvider; -public record UserSignupCommand( +public record UserRegisterCommand( String nickname, - SocialProvider provider, - String imgUrl + String imgUrl, + String providerId, + SocialProvider socialProvider ) { } diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecase.java deleted file mode 100644 index 16520f59..00000000 --- a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecase.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.runimo.runimo.user.service.usecases.auth; - -import org.runimo.runimo.user.domain.SocialProvider; -import org.runimo.runimo.user.service.dtos.AuthResponse; -import org.runimo.runimo.user.service.dtos.SignupUserResponse; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; - -public interface UserOAuthUsecase { - AuthResponse validateAndLogin(final String rawToken, final SocialProvider provider); - - SignupUserResponse validateAndSignup(final UserSignupCommand command, final String newToken, final SocialProvider socialProvider); -} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecaseImpl.java deleted file mode 100644 index 484a78a8..00000000 --- a/src/main/java/org/runimo/runimo/user/service/usecases/auth/UserOAuthUsecaseImpl.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.runimo.runimo.user.service.usecases.auth; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.interfaces.DecodedJWT; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.runimo.runimo.auth.jwt.JwtTokenFactory; -import org.runimo.runimo.auth.service.OidcNonceService; -import org.runimo.runimo.auth.service.OidcService; -import org.runimo.runimo.user.domain.OAuthInfo; -import org.runimo.runimo.user.domain.SocialProvider; -import org.runimo.runimo.user.domain.User; -import org.runimo.runimo.user.enums.UserHttpResponseCode; -import org.runimo.runimo.user.exceptions.SignUpException; -import org.runimo.runimo.user.repository.OAuthInfoRepository; -import org.runimo.runimo.user.service.dtos.AuthResponse; -import org.runimo.runimo.user.service.dtos.SignupUserResponse; -import org.runimo.runimo.user.service.dtos.TokenPair; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserOAuthUsecaseImpl implements UserOAuthUsecase { - private final JwtTokenFactory jwtfactory; - private final OidcService oidcService; - private final OidcNonceService oidcNonceService; - private final OAuthInfoRepository oAuthInfoRepository; - private final UserRegisterService userRegisterService; - - @Override - @Transactional - public AuthResponse validateAndLogin(final String rawToken, final SocialProvider provider) { - DecodedJWT token = JWT.decode(rawToken); - String pid = oidcService.validateOidcTokenAndGetProviderId(token, provider); - OAuthInfo oAuthInfo = oAuthInfoRepository.findByProviderAndProviderId(provider, pid) - .orElseThrow(() -> new SignUpException(UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN)); - TokenPair tokenPair = jwtfactory.generateTokenPair(oAuthInfo.getUser()); - return new AuthResponse(oAuthInfo.getUser(), tokenPair); - } - - @Override - @Transactional - public SignupUserResponse validateAndSignup(final UserSignupCommand command, final String rawToken, SocialProvider provider) { - DecodedJWT token = JWT.decode(rawToken); - String pid = oidcService.validateOidcTokenAndGetProviderId(token, provider); - oAuthInfoRepository.findByProviderAndProviderId(provider, pid) - .ifPresent(oAuthInfo -> { - throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST); - }); - User savedUser = userRegisterService.register(command, pid); - TokenPair tokenPair = jwtfactory.generateTokenPair(savedUser); - return new SignupUserResponse(savedUser, tokenPair); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1e63a569..774197f6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,13 @@ spring: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo +apple: + client-id: ${APPLE_CLIENT_ID} + client-secret: ${APPLE_PRIVATE_KEY} + redirect-uri: ${APPLE_REDIRECT_URI} + team-id: ${APPLE_TEAM_ID} + key-id: ${APPLE_KEY_ID} + springdoc: swagger-ui: path: /swagger-ui.html @@ -47,6 +54,8 @@ jwt: expiration: ${JWT_EXPIRATION:3600000} refresh: expiration: ${JWT_REFRESH_EXPIRATION:86400000} + temp: + expiration: ${JWT_REGISTER_EXPIRATION} logging: level: diff --git a/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..9fdb992a --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java @@ -0,0 +1,125 @@ +package org.runimo.runimo.auth.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.auth.exceptions.UnRegisteredUserException; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.service.OidcService; +import org.runimo.runimo.auth.service.SignUpUsecase; +import org.runimo.runimo.auth.service.TokenRefreshService; +import org.runimo.runimo.auth.service.dtos.AuthResponse; +import org.runimo.runimo.configs.ControllerTest; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.UserRegisterService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ControllerTest(controllers = {AuthController.class}) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OidcService oidcService; + + @MockitoBean + private TokenRefreshService tokenRefreshService; + + @MockitoBean + private SignUpUsecase signUpUsecase; + + @MockitoBean + private UserRegisterService userRegisterService; + + @MockitoBean + private UserFinder userFinder; + + @MockitoBean + private JwtResolver jwtResolver; + + @Test + void 카카오_로그인_INVALID_401응답() throws Exception { + // given + given(oidcService.kakaoLogin(any())).willThrow(UserJwtException.of(UserHttpResponseCode.LOGIN_FAIL_INVALID)); + // when & then + mockMvc.perform(post("/api/v1/auth/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"oidcToken\":\"invalid-token\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UserHttpResponseCode.LOGIN_FAIL_INVALID.getCode())) + .andExpect(jsonPath("$.message").value(UserHttpResponseCode.LOGIN_FAIL_INVALID.getClientMessage())); + } + + @Test + void 카카오_로그인_미등록_사용자_404응답() throws Exception { + // given + given(oidcService.kakaoLogin(any())).willThrow( + UnRegisteredUserException.of(UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN, "temp-token") + ); + + // when & then + mockMvc.perform(post("/api/v1/auth/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"oidcToken\":\"valid-token\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value(UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN.getCode())) + .andExpect(jsonPath("$.message").value(UserHttpResponseCode.LOGIN_FAIL_NOT_SIGN_IN.getClientMessage())); + } + + @Test + void 카카오_로그인_성공_200응답() throws Exception { + // given + AuthResponse authResponse = new AuthResponse("test-nickname", "imgurl", "access-token", "refresh-token"); + given(oidcService.kakaoLogin(any())).willReturn(authResponse); + + // when & then + mockMvc.perform(post("/api/v1/auth/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"oidcToken\":\"valid-token\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(UserHttpResponseCode.LOGIN_SUCCESS.getCode())) + .andExpect(jsonPath("$.payload.nickname").value("test-nickname")) + .andExpect(jsonPath("$.payload.img_url").value("imgurl")) + .andExpect(jsonPath("$.payload.access_token").value("access-token")) + .andExpect(jsonPath("$.payload.refresh_token").value("refresh-token")); + } + + @Test + void 카카오_로그인_서버에러_500응답() throws Exception { + // given + given(oidcService.kakaoLogin(any())).willThrow(new RuntimeException("Unexpected error")); + + // when & then + mockMvc.perform(post("/api/v1/auth/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"oidcToken\":\"valid-token\"}")) + .andExpect(status().isInternalServerError()); + } + + @Test + @DisplayName("유효하지 않은 토큰으로 회원가입 시 401 응답") + void 회원가입_토큰_INVALID_401응답() throws Exception { + // given + given(signUpUsecase.register(any())) + .willThrow(UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID)); + + // when & then + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"register_token\":\"invalid-token\", \"nickname\":\"RunimoUser\", \"img_url\":\"https://example.com/image.jpg\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(UserHttpResponseCode.TOKEN_INVALID.getCode())) + .andExpect(jsonPath("$.message").value(UserHttpResponseCode.TOKEN_INVALID.getClientMessage())); + } +} diff --git a/src/test/java/org/runimo/runimo/configs/ControllerTest.java b/src/test/java/org/runimo/runimo/configs/ControllerTest.java index af988851..2cac4a75 100644 --- a/src/test/java/org/runimo/runimo/configs/ControllerTest.java +++ b/src/test/java/org/runimo/runimo/configs/ControllerTest.java @@ -1,5 +1,6 @@ package org.runimo.runimo.configs; +import org.runimo.runimo.exceptions.GlobalExceptionHandler; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -15,7 +16,7 @@ @ActiveProfiles("test") @WebMvcTest @AutoConfigureMockMvc -@Import({TestConfig.class, TestWebMvcConfig.class, TestSecurityConfig.class}) +@Import({TestConfig.class, TestWebMvcConfig.class, TestSecurityConfig.class, GlobalExceptionHandler.class}) public @interface ControllerTest { Class[] controllers() default {}; } \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/configs/TestConfig.java b/src/test/java/org/runimo/runimo/configs/TestConfig.java index 104d00fe..a89dca89 100644 --- a/src/test/java/org/runimo/runimo/configs/TestConfig.java +++ b/src/test/java/org/runimo/runimo/configs/TestConfig.java @@ -10,7 +10,7 @@ public class TestConfig { @Bean public JwtTokenFactory jwtTokenFactory() { - return new JwtTokenFactory("testSecret", 1000L, 3600L); + return new JwtTokenFactory("testSecret", 1000L, 3600L, 600L); } @Bean diff --git a/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java b/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java index 423ee080..a53ddd13 100644 --- a/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java +++ b/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java @@ -10,7 +10,6 @@ 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.AuthenticationFailureHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @TestConfiguration @@ -19,12 +18,9 @@ public class TestSecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final AuthenticationFailureHandler customAuthenticationFailureHandler; - public TestSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, - AuthenticationFailureHandler customAuthenticationFailureHandler) { + public TestSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.customAuthenticationFailureHandler = customAuthenticationFailureHandler; } @Bean @@ -34,13 +30,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .oauth2Login(oAuth2Login -> { - oAuth2Login - .loginPage("/api/v1/users/auth/login") - .failureHandler(customAuthenticationFailureHandler); - }) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/users/auth/**").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/test/java/org/runimo/runimo/hatch/controller/HatchControllerTest.java b/src/test/java/org/runimo/runimo/hatch/controller/HatchControllerTest.java index b12bdbcf..d347f6da 100644 --- a/src/test/java/org/runimo/runimo/hatch/controller/HatchControllerTest.java +++ b/src/test/java/org/runimo/runimo/hatch/controller/HatchControllerTest.java @@ -24,91 +24,91 @@ @ActiveProfiles("test") class HatchControllerTest { - @LocalServerPort - int port; + @LocalServerPort + int port; - @Autowired - private JwtTokenFactory jwtTokenFactory; + @Autowired + private JwtTokenFactory jwtTokenFactory; - @Autowired - private CleanUpUtil cleanUpUtil; + @Autowired + private CleanUpUtil cleanUpUtil; - @Autowired - private ObjectMapper objectMapper; + @Autowired + private ObjectMapper objectMapper; - @BeforeEach - void setUp() { - RestAssured.port = port; - } + @BeforeEach + void setUp() { + RestAssured.port = port; + } - @AfterEach - void tearDown() { - cleanUpUtil.cleanUpUserInfos(); - } + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } - @Test - @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 사용자의_알_부화_성공() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + @Test + @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 사용자의_알_부화_성공() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .post("/api/v1/incubating-eggs/"+"1"+"/hatch") + .when() + .post("/api/v1/incubating-eggs/" + "1" + "/hatch") - .then() - .log().all() - .statusCode(HttpStatus.CREATED.value()) + .then() + .log().all() + .statusCode(HttpStatus.CREATED.value()) - .body("code", equalTo("HSH2011")) - .body("payload.name", equalTo("토끼_dummy")) - .body("payload.img_url", equalTo("http://dummy")) - .body("payload.code", equalTo("R-100")) - .body("payload.is_duplicated", equalTo(false)); - } + .body("code", equalTo("HSH2011")) + .body("payload.name", equalTo("토끼_dummy")) + .body("payload.img_url", equalTo("http://dummy")) + .body("payload.code", equalTo("R-100")) + .body("payload.is_duplicated", equalTo(false)); + } - @Test - @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 사용자의_알_부화_실패_부화가능한_상태가_아님() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - CustomResponseCode responseCode = HatchHttpResponseCode.HATCH_EGG_NOT_READY; + @Test + @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 사용자의_알_부화_실패_부화가능한_상태가_아님() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + CustomResponseCode responseCode = HatchHttpResponseCode.HATCH_EGG_NOT_READY; - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .post("/api/v1/incubating-eggs/"+"2"+"/hatch") + .when() + .post("/api/v1/incubating-eggs/" + "2" + "/hatch") - .then() - .log().all() - .statusCode(responseCode.getHttpStatusCode().value()) + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) - .body("code", equalTo(responseCode.getCode())) - .body("message", equalTo(responseCode.getClientMessage())); - } + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } - @Test - @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 사용자의_알_부화_실패_알_존재하지_않음() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - CustomResponseCode responseCode = HatchHttpResponseCode.HATCH_EGG_NOT_FOUND; + @Test + @Sql(scripts = "/sql/hatch_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 사용자의_알_부화_실패_알_존재하지_않음() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + CustomResponseCode responseCode = HatchHttpResponseCode.HATCH_EGG_NOT_FOUND; - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .post("/api/v1/incubating-eggs/"+"9999"+"/hatch") + .when() + .post("/api/v1/incubating-eggs/" + "9999" + "/hatch") - .then() - .log().all() - .statusCode(responseCode.getHttpStatusCode().value()) + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) - .body("code", equalTo(responseCode.getCode())) - .body("message", equalTo(responseCode.getClientMessage())); - } + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } } \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/item/domain/IncubatingEggTest.java b/src/test/java/org/runimo/runimo/item/domain/IncubatingEggTest.java index d1ea9efa..9e0e378c 100644 --- a/src/test/java/org/runimo/runimo/item/domain/IncubatingEggTest.java +++ b/src/test/java/org/runimo/runimo/item/domain/IncubatingEggTest.java @@ -1,11 +1,12 @@ package org.runimo.runimo.item.domain; -import static org.junit.jupiter.api.Assertions.*; - import org.junit.jupiter.api.Test; import org.runimo.runimo.user.domain.EggStatus; import org.runimo.runimo.user.domain.IncubatingEgg; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + class IncubatingEggTest { @Test diff --git a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java index 71950332..dc7c12f5 100644 --- a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java @@ -27,13 +27,12 @@ @ActiveProfiles("test") class RecordAcceptanceTest { + private static final String USER_UUID = "test-user-uuid-1"; + private static final String AUTH_HEADER_PREFIX = "Bearer "; @LocalServerPort int port; - @Autowired private JwtTokenFactory jwtTokenFactory; - private static final String USER_UUID = "test-user-uuid-1"; - private static final String AUTH_HEADER_PREFIX = "Bearer "; @Autowired private ObjectMapper objectMapper; @Autowired @@ -135,7 +134,6 @@ void tearDown() { } - @Test @WithMockUser(username = USER_UUID) @Sql(scripts = "/sql/weekly_record_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) diff --git a/src/test/java/org/runimo/runimo/rewards/RewardTest.java b/src/test/java/org/runimo/runimo/rewards/RewardTest.java index 04982a1d..48a06acb 100644 --- a/src/test/java/org/runimo/runimo/rewards/RewardTest.java +++ b/src/test/java/org/runimo/runimo/rewards/RewardTest.java @@ -4,6 +4,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.SignUpUsecaseImpl; +import org.runimo.runimo.auth.service.dtos.UserSignupCommand; import org.runimo.runimo.common.scale.Distance; import org.runimo.runimo.common.scale.Pace; import org.runimo.runimo.records.service.usecases.RecordCreateUsecase; @@ -15,9 +18,8 @@ import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.repository.UserRepository; import org.runimo.runimo.user.service.UserItemFinder; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; -import org.runimo.runimo.user.service.usecases.auth.UserRegisterService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -34,7 +36,7 @@ class RewardTest { @Autowired - private UserRegisterService userRegisterService; + private SignUpUsecaseImpl signUpUsecaseImpl; @Autowired private RewardService rewardService; @@ -46,12 +48,18 @@ class RewardTest { private UserItemFinder userItemFinder; @Autowired private CleanUpUtil cleanUpUtil; + @Autowired + private JwtTokenFactory jwtTokenFactory; + @Autowired + private UserRepository userRepository; @BeforeEach void setUp() { //given - UserSignupCommand command = new UserSignupCommand("test", SocialProvider.KAKAO, "1234"); - savedUser = userRegisterService.register(command, "1234"); + String registerToken = jwtTokenFactory.generateRegisterTemporalToken("test-pid", SocialProvider.KAKAO); + UserSignupCommand command = new UserSignupCommand(registerToken, "name", "1234"); + Long useId = signUpUsecaseImpl.register(command).userId(); + savedUser = userRepository.findById(useId).orElse(null); } @AfterEach diff --git a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java index f2701bec..3ad9eeac 100644 --- a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java @@ -30,18 +30,16 @@ @ActiveProfiles("test") class RewardAcceptanceTest { + private static final LocalDateTime pivotTime = LocalDateTime.of(2023, 10, 1, 10, 0); @LocalServerPort private int port; @Autowired private JwtTokenFactory jwtTokenFactory; - @Autowired private CleanUpUtil cleanUpUtil; @Autowired private ObjectMapper objectMapper; - private static final LocalDateTime pivotTime = LocalDateTime.of(2023, 10, 1, 10, 0); - @BeforeEach void setUp() { RestAssured.port = port; diff --git a/src/test/java/org/runimo/runimo/runimo/controller/RunimoControllerTest.java b/src/test/java/org/runimo/runimo/runimo/controller/RunimoControllerTest.java index 846f8ff4..6fb50e6e 100644 --- a/src/test/java/org/runimo/runimo/runimo/controller/RunimoControllerTest.java +++ b/src/test/java/org/runimo/runimo/runimo/controller/RunimoControllerTest.java @@ -24,115 +24,115 @@ @ActiveProfiles("test") class RunimoControllerTest { - @LocalServerPort - int port; + @LocalServerPort + int port; - @Autowired - private JwtTokenFactory jwtTokenFactory; + @Autowired + private JwtTokenFactory jwtTokenFactory; - @Autowired - private CleanUpUtil cleanUpUtil; + @Autowired + private CleanUpUtil cleanUpUtil; - @Autowired - private ObjectMapper objectMapper; + @Autowired + private ObjectMapper objectMapper; - @BeforeEach - void setUp() { - RestAssured.port = port; - } + @BeforeEach + void setUp() { + RestAssured.port = port; + } - @AfterEach - void tearDown() { - cleanUpUtil.cleanUpUserInfos(); - } + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } - @Test - @Sql(scripts = "/sql/get_my_runimo_list_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 보유_러니모_목록_조회_성공() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + @Test + @Sql(scripts = "/sql/get_my_runimo_list_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 보유_러니모_목록_조회_성공() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .get("/api/v1/runimos/my") + .when() + .get("/api/v1/runimos/my") - .then() - .log().all() - .statusCode(HttpStatus.OK.value()) + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) - .body("code", equalTo("MSH2001")) - .body("payload.my_runimos[0].id", equalTo(1)) - .body("payload.my_runimos[0].name", equalTo("토끼")) - .body("payload.my_runimos[0].img_url", equalTo("http://dummy1")) - .body("payload.my_runimos[0].code", equalTo("R-101")) - .body("payload.my_runimos[0].egg_type", equalTo("MADANG")) - .body("payload.my_runimos[0].description", equalTo("마당 토끼예여")); - } + .body("code", equalTo("MSH2001")) + .body("payload.my_runimos[0].id", equalTo(1)) + .body("payload.my_runimos[0].name", equalTo("토끼")) + .body("payload.my_runimos[0].img_url", equalTo("http://dummy1")) + .body("payload.my_runimos[0].code", equalTo("R-101")) + .body("payload.my_runimos[0].egg_type", equalTo("MADANG")) + .body("payload.my_runimos[0].description", equalTo("마당 토끼예여")); + } - @Test - @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 대표_러니모_설정_성공() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + @Test + @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 대표_러니모_설정_성공() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .patch("/api/v1/runimos/"+"1"+"/main") + .when() + .patch("/api/v1/runimos/" + "1" + "/main") - .then() - .log().all() - .statusCode(HttpStatus.OK.value()) + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) - .body("code", equalTo("MSH2002")) - .body("payload.main_runimo_id", equalTo(1)); - } + .body("code", equalTo("MSH2002")) + .body("payload.main_runimo_id", equalTo(1)); + } - @Test - @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 대표_러니모_설정_실패_러니모의_소유자_아님() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - CustomResponseCode responseCode = RunimoHttpResponseCode.USER_DO_NOT_OWN_RUNIMO; + @Test + @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 대표_러니모_설정_실패_러니모의_소유자_아님() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + CustomResponseCode responseCode = RunimoHttpResponseCode.USER_DO_NOT_OWN_RUNIMO; - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .patch("/api/v1/runimos/"+"4"+"/main") + .when() + .patch("/api/v1/runimos/" + "4" + "/main") - .then() - .log().all() - .statusCode(responseCode.getHttpStatusCode().value()) + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) - .body("code", equalTo(responseCode.getCode())) - .body("message", equalTo(responseCode.getClientMessage())); - } + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } - @Test - @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 대표_러니모_설정_실패_러니모_존재하지않음() { - String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - CustomResponseCode responseCode = RunimoHttpResponseCode.RUNIMO_NOT_FOUND; + @Test + @Sql(scripts = "/sql/set_main_runimo_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 대표_러니모_설정_실패_러니모_존재하지않음() { + String token = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + CustomResponseCode responseCode = RunimoHttpResponseCode.RUNIMO_NOT_FOUND; - given() - .header("Authorization", token) - .contentType(ContentType.JSON) + given() + .header("Authorization", token) + .contentType(ContentType.JSON) - .when() - .patch("/api/v1/runimos/"+"9999"+"/main") + .when() + .patch("/api/v1/runimos/" + "9999" + "/main") - .then() - .log().all() - .statusCode(responseCode.getHttpStatusCode().value()) + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) - .body("code", equalTo(responseCode.getCode())) - .body("message", equalTo(responseCode.getClientMessage())); - } + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } } \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/api/IncubatingEggAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/IncubatingEggAcceptanceTest.java index db16dbc6..841029db 100644 --- a/src/test/java/org/runimo/runimo/user/api/IncubatingEggAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/IncubatingEggAcceptanceTest.java @@ -9,8 +9,8 @@ import org.junit.jupiter.api.Test; import org.runimo.runimo.CleanUpUtil; import org.runimo.runimo.auth.jwt.JwtTokenFactory; -import org.runimo.runimo.user.controller.request.UseLovePointRequest; import org.runimo.runimo.user.controller.request.RegisterEggRequest; +import org.runimo.runimo.user.controller.request.UseLovePointRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -18,9 +18,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.jdbc.Sql; - import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") diff --git a/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java index bc3c277a..a9760b87 100644 --- a/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java @@ -14,24 +14,21 @@ import org.springframework.test.context.jdbc.Sql; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.equalTo; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") class MainViewAcceptanceTest { + private static final String USER_UUID = "test-user-uuid-1"; + private static final String AUTH_HEADER_PREFIX = "Bearer "; @LocalServerPort int port; - @Autowired private JwtTokenFactory jwtTokenFactory; - @Autowired private CleanUpUtil cleanUpUtil; - private static final String USER_UUID = "test-user-uuid-1"; - private static final String AUTH_HEADER_PREFIX = "Bearer "; - @BeforeEach void setUp() { RestAssured.port = port; diff --git a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java index 6d44a11e..bf7b3a86 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java @@ -9,10 +9,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.controller.request.AuthSignupRequest; import org.runimo.runimo.auth.jwt.JwtTokenFactory; -import org.runimo.runimo.auth.service.OidcService; -import org.runimo.runimo.user.controller.request.AuthSignupRequest; +import org.runimo.runimo.auth.service.SignUpUsecaseImpl; +import org.runimo.runimo.auth.service.dtos.SignupUserResponse; +import org.runimo.runimo.auth.service.dtos.TokenPair; import org.runimo.runimo.user.controller.request.UseItemRequest; +import org.runimo.runimo.user.domain.SocialProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -39,7 +42,8 @@ class UserItemAcceptanceTest { private JwtTokenFactory jwtTokenFactory; @MockitoBean - private OidcService oidcService; + private SignUpUsecaseImpl signUpUsecaseImpl; + @Autowired private CleanUpUtil cleanUpUtil; @@ -124,18 +128,28 @@ void tearDown() { @Test @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) - void 회원가입후_알_지급_성공() throws JsonProcessingException { + void 카카오_회원가입후_알_지급_성공() throws JsonProcessingException { + String registerToken = jwtTokenFactory.generateRegisterTemporalToken("test-pid", SocialProvider.KAKAO); String token = jwtTokenFactory.generateAccessToken("test-user-uuid-1"); - when(oidcService.validateOidcTokenAndGetProviderId(any(), any())) - .thenReturn("123"); - - AuthSignupRequest request = new AuthSignupRequest(token, "KAKAO", "1234", "https://example.com/image.jpg"); + when(signUpUsecaseImpl.register(any())) + .thenReturn(new SignupUserResponse( + 1L, + "test-user", + "https://test-image.com", + new TokenPair(token, "token2") + )); + + AuthSignupRequest request = new AuthSignupRequest( + registerToken, + "test-user", + "https://test-image.com" + ); ValidatableResponse res = given() .body(objectMapper.writeValueAsString(request)) .contentType(ContentType.JSON) .when() - .post("/api/v1/users/auth/signup") + .post("/api/v1/auth/signup") .then() .log().ifError() .statusCode(HttpStatus.CREATED.value()); diff --git a/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java index 3b8f830e..429bf9b8 100644 --- a/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java @@ -3,10 +3,10 @@ import org.junit.jupiter.api.Test; import org.runimo.runimo.auth.jwt.JwtTokenFactory; import org.runimo.runimo.configs.ControllerTest; -import org.runimo.runimo.user.service.usecases.query.MainViewQueryUsecase; -import org.runimo.runimo.user.service.dtos.MainViewResponse; import org.runimo.runimo.user.UserFixtures; import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.dtos.MainViewResponse; +import org.runimo.runimo.user.service.usecases.query.MainViewQueryUsecase; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; diff --git a/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java index 22c10b1a..e31b1450 100644 --- a/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java @@ -4,8 +4,8 @@ import org.runimo.runimo.auth.jwt.JwtResolver; import org.runimo.runimo.auth.jwt.JwtTokenFactory; import org.runimo.runimo.configs.ControllerTest; -import org.runimo.runimo.user.service.dtos.MyPageViewResponse; import org.runimo.runimo.user.service.dtos.LatestRunningRecord; +import org.runimo.runimo.user.service.dtos.MyPageViewResponse; import org.runimo.runimo.user.service.usecases.query.MyPageQueryUsecase; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -17,13 +17,12 @@ import javax.naming.NoPermissionException; import java.time.LocalDateTime; +import java.util.NoSuchElementException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @ControllerTest(controllers = {MyPageController.class}) class MyPageControllerTest { @@ -53,7 +52,7 @@ class MyPageControllerTest { 5L, new LatestRunningRecord( "활기차 모닝런", - LocalDateTime.of(2025, 3, 24,10,11), + LocalDateTime.of(2025, 3, 24, 10, 11), 3000L, 100L, 6700L @@ -62,7 +61,7 @@ class MyPageControllerTest { when(myPageQueryUsecase.execute(any())) .thenReturn(response); - when(jwtResolver.getUserIdFromAccessToken(any())) + when(jwtResolver.getUserIdFromJwtToken(any())) .thenReturn("test-user-uuid-1"); when(userIdResolver.resolveArgument(any(), any(), any(), any())) .thenReturn(1L); @@ -105,14 +104,13 @@ class MyPageControllerTest { String accessToken = "Bearer " + jwtTokenFactory.generateAccessToken("non-existent-user"); when(myPageQueryUsecase.execute(any())) - .thenThrow(new RuntimeException("User not found")); + .thenThrow(new NoSuchElementException("User not found")); // when & then - mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/users/me") .header("Authorization", accessToken) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) - .andExpect(status().isUnauthorized()); + .andExpect(status().isNotFound()); } } \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java index b2721a4b..df3cc396 100644 --- a/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java @@ -38,7 +38,8 @@ class QueryItemControllerTest { @Test void 보유한_아이템_조회_성공() throws Exception { - String accessToken = jwtTokenFactory.generateAccessToken("test-user-uuid-1");given(myItemQueryUsecase.queryMyAllItems(any())) + String accessToken = jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + given(myItemQueryUsecase.queryMyAllItems(any())) .willReturn(new ItemQueryResponse(new ArrayList<>())); given(userFinder.findUserByPublicId(any())) .willReturn(Optional.of(UserFixtures.getDefaultUser())); diff --git a/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java b/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java index 7e7df3da..f1adedf3 100644 --- a/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java +++ b/src/test/java/org/runimo/runimo/user/service/usecases/UserRegisterServiceTest.java @@ -10,8 +10,8 @@ import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.service.UserCreator; import org.runimo.runimo.user.service.UserItemCreator; -import org.runimo.runimo.user.service.dtos.UserSignupCommand; -import org.runimo.runimo.user.service.usecases.auth.UserRegisterService; +import org.runimo.runimo.user.service.UserRegisterService; +import org.runimo.runimo.user.service.dtos.UserRegisterCommand; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; @@ -39,17 +39,25 @@ void setUp() { @Test void 회원가입_알_지급_테스트() { // given - UserSignupCommand command = new UserSignupCommand("test", SocialProvider.KAKAO, "1234"); + String providerId = "providerId"; + UserRegisterCommand command = + new UserRegisterCommand( + "test-nickname", + "https://test.com", + providerId, + SocialProvider.KAKAO + ); User mockUser = mock(User.class); - when(userCreator.createUser(any(UserSignupCommand.class))).thenReturn(mockUser); + when(userCreator.createUser(any())).thenReturn(mockUser); + // when - User createdUser = userRegisterService.register(command, "1234"); + User res = userRegisterService.registerUser(command); // then - assertNotNull(createdUser); - verify(userCreator, times(1)).createUser(command); - verify(userCreator, times(1)).createUserOAuthInfo(mockUser, SocialProvider.KAKAO, "1234"); + assertNotNull(res); + verify(userCreator, times(1)).createUser(any()); + verify(userCreator, times(1)).createUserOAuthInfo(mockUser, SocialProvider.KAKAO, providerId); verify(userCreator, times(1)).createLovePoint(anyLong()); verify(userItemCreator, times(1)).createAll(anyLong()); verify(eggGrantService, times(1)).grantGreetingEggToUser(mockUser); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index d325250e..c8db9aad 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -55,6 +55,15 @@ jwt: expiration: ${JWT_EXPIRATION:3600000} refresh: expiration: ${JWT_REFRESH_EXPIRATION:86400000} + temp: + expiration: ${JWT_REGISTER_EXPIRATION} + +apple: + client-id: ${APPLE_CLIENT_ID} + client-secret: ${APPLE_PRIVATE_KEY} + redirect-uri: ${APPLE_REDIRECT_URI} + team-id: ${APPLE_TEAM_ID} + key-id: ${APPLE_KEY_ID} logging: level: