diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java new file mode 100644 index 000000000..aef1309af --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -0,0 +1,83 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.auth.dto.oauth.AppleTokenDto; +import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto; +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.security.PublicKey; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; + +/* + * 애플 인증을 위한 OAuth2 클라이언트 + * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClient { + + private final RestTemplate restTemplate; + private final AppleOAuthClientProperties properties; + private final AppleOAuthClientSecretProvider clientSecretProvider; + private final ApplePublicKeyProvider publicKeyProvider; + + public AppleUserInfoDto processOAuth(String code) { + String idToken = requestIdToken(code); + PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken); + return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken)); + } + + public String requestIdToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap formData = buildFormData(code); + + try { + ResponseEntity response = restTemplate.exchange( + properties.tokenUrl(), + HttpMethod.POST, + new HttpEntity<>(formData, headers), + AppleTokenDto.class + ); + return Objects.requireNonNull(response.getBody()).idToken(); + } catch (Exception e) { + throw new CustomException(APPLE_AUTHORIZATION_FAILED, e.getMessage()); + } + } + + private MultiValueMap buildFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", properties.clientId()); + formData.add("client_secret", clientSecretProvider.generateClientSecret()); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", properties.redirectUrl()); + return formData; + } + + private String parseEmailFromToken(PublicKey applePublicKey, String idToken) { + try { + return Jwts.parser() + .setSigningKey(applePublicKey) + .parseClaimsJws(idToken) + .getBody() + .get("email", String.class); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java new file mode 100644 index 000000000..2de0b7291 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; + +/* + * 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다. + * 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다. + * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClientSecretProvider { + + private static final String KEY_ID_HEADER = "kid"; + private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min + private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; + + private final AppleOAuthClientProperties appleOAuthClientProperties; + private PrivateKey privateKey; + + @PostConstruct + private void initPrivateKey() { + privateKey = readPrivateKey(); + } + + public String generateClientSecret() { + Date now = new Date(); + Date expiration = new Date(now.getTime() + TOKEN_DURATION); + + return Jwts.builder() + .setHeaderParam("alg", "ES256") + .setHeaderParam(KEY_ID_HEADER, appleOAuthClientProperties.keyId()) + .setSubject(appleOAuthClientProperties.clientId()) + .setIssuer(appleOAuthClientProperties.teamId()) + .setAudience(appleOAuthClientProperties.clientSecretAudienceUrl()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.ES256, privateKey) + .compact(); + } + + private PrivateKey readPrivateKey() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + + String secretKey = reader.lines().collect(Collectors.joining("\n")); + byte[] encoded = Base64.decodeBase64(secretKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java new file mode 100644 index 000000000..1cc708cc7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -0,0 +1,94 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; +import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; + +/* +* idToken 검증을 위해서 애플의 공개키를 가져온다. +* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. +* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. +* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. +* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature +* */ +@Component +@RequiredArgsConstructor +public class ApplePublicKeyProvider { + + private final AppleOAuthClientProperties properties; + private final RestTemplate restTemplate; + + private final Map applePublicKeyCache = new ConcurrentHashMap<>(); + + public PublicKey getApplePublicKey(String idToken) { + try { + String kid = getKeyIdFromTokenHeader(idToken); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } + + fetchApplePublicKeys(); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } else { + throw new CustomException(APPLE_PUBLIC_KEY_NOT_FOUND); + } + } catch (ExpiredJwtException e) { + throw new CustomException(APPLE_ID_TOKEN_EXPIRED); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } + + /* + * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 + * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. + * */ + private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length < 2) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0]), StandardCharsets.UTF_8); + return new ObjectMapper().readTree(headerJson).get("kid").asText(); + } + + private void fetchApplePublicKeys() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResponseEntity response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class); + JsonNode jsonNode = objectMapper.readTree(response.getBody()).get("keys"); + + applePublicKeyCache.clear(); + for (JsonNode key : jsonNode) { + applePublicKeyCache.put(key.get("kid").asText(), generatePublicKey(key)); + } + } + + private PublicKey generatePublicKey(JsonNode key) throws Exception { + BigInteger modulus = new BigInteger(1, decodeBase64URLSafe(key.get("n").asText())); + BigInteger exponent = new BigInteger(1, decodeBase64URLSafe(key.get("e").asText())); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 9862d0074..5d625cb7c 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -1,10 +1,10 @@ package com.example.solidconnection.auth.client; -import com.example.solidconnection.auth.dto.kakao.KakaoTokenDto; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.config.client.KakaoOAuthClientProperties; import com.example.solidconnection.custom.exception.CustomException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -20,38 +20,24 @@ import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL; +/* + * 카카오 인증을 위한 OAuth2 클라이언트 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * */ @Component @RequiredArgsConstructor public class KakaoOAuthClient { private final RestTemplate restTemplate; + private final KakaoOAuthClientProperties kakaoOAuthClientProperties; - @Value("${kakao.redirect_uri}") - public String redirectUri; - - @Value("${kakao.client_id}") - private String clientId; - - @Value("${kakao.token_url}") - private String tokenUrl; - - @Value("${kakao.user_info_url}") - private String userInfoUrl; - - /* - * 클라이언트에서 사용자가 카카오 로그인을 하면, 클라이언트는 '카카오 인가 코드'를 받아, 서버에 넘겨준다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code - * 서버는 카카오 인증 코드를 사용해 카카오 서버로부터 '카카오 토큰'을 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token - * 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info - * */ - public KakaoUserInfoDto processOauth(String code) { + public KakaoUserInfoDto getUserInfo(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } - // 카카오 토큰 요청 private String getKakaoAccessToken(String code) { try { ResponseEntity response = restTemplate.exchange( @@ -72,30 +58,26 @@ private String getKakaoAccessToken(String code) { } } - // 카카오 엑세스 토큰 요청하는 URI 생성 private String buildTokenUri(String code) { - return UriComponentsBuilder.fromHttpUrl(tokenUrl) + return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl()) .queryParam("grant_type", "authorization_code") - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) + .queryParam("client_id", kakaoOAuthClientProperties.clientId()) + .queryParam("redirect_uri", kakaoOAuthClientProperties.redirectUrl()) .queryParam("code", code) .toUriString(); } - // 카카오 사용자 정보 요청 private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); - // 사용자의 정보 요청 ResponseEntity response = restTemplate.exchange( - userInfoUrl, + kakaoOAuthClientProperties.userInfoUrl(), HttpMethod.GET, new HttpEntity<>(headers), KakaoUserInfoDto.class ); - // 응답 예외처리 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { return response.getBody(); } else { diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 1f6415157..aa3ce4f20 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -1,13 +1,14 @@ package com.example.solidconnection.auth.controller; import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.auth.service.oauth.AppleOAuthService; +import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; import com.example.solidconnection.custom.resolver.AuthorizedUser; import com.example.solidconnection.custom.resolver.ExpiredToken; import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; @@ -27,23 +28,32 @@ public class AuthController { private final AuthService authService; - private final SignUpService signUpService; - private final SignInService signInService; + private final OAuthSignUpService oAuthSignUpService; + private final AppleOAuthService appleOAuthService; + private final KakaoOAuthService kakaoOAuthService; + + @PostMapping("/apple") + public ResponseEntity processAppleOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } @PostMapping("/kakao") - public ResponseEntity processKakaoOauth( - @RequestBody KakaoCodeRequest kakaoCodeRequest + public ResponseEntity processKakaoOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest ) { - KakaoOauthResponse kakaoOauthResponse = signInService.signIn(kakaoCodeRequest); - return ResponseEntity.ok(kakaoOauthResponse); + OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); } @PostMapping("/sign-up") - public ResponseEntity signUp( + public ResponseEntity signUp( @Valid @RequestBody SignUpRequest signUpRequest ) { - SignUpResponse signUpResponseDto = signUpService.signUp(signUpRequest); - return ResponseEntity.ok(signUpResponseDto); + SignInResponse signInResponse = oAuthSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); } @PostMapping("/sign-out") diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index 400491b42..a4ae442e2 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,9 +1,7 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; - public record SignInResponse( - boolean isRegistered, String accessToken, - String refreshToken) implements KakaoOauthResponse { + String refreshToken +) { } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index fcb68cad1..b28b467bd 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PreparationStatus; @@ -10,7 +11,7 @@ import java.util.List; public record SignUpRequest( - String kakaoOauthToken, + String signUpToken, List interestedRegions, List interestedCountries, PreparationStatus preparationStatus, @@ -23,15 +24,16 @@ public record SignUpRequest( @JsonFormat(pattern = "yyyy-MM-dd") String birth) { - public SiteUser toSiteUser(String email, Role role) { + public SiteUser toSiteUser(String email, AuthType authType) { return new SiteUser( email, this.nickname, this.profileImageUrl, this.birth, this.preparationStatus, - role, - this.gender + Role.MENTEE, + this.gender, + authType ); } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java deleted file mode 100644 index 2d74610cc..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.solidconnection.auth.dto; - -public record SignUpResponse( - String accessToken, - String refreshToken) { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java deleted file mode 100644 index 6d7130bf0..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record FirstAccessResponse( - boolean isRegistered, - String nickname, - String email, - String profileImageUrl, - String kakaoOauthToken) implements KakaoOauthResponse { - - public static FirstAccessResponse of(KakaoUserInfoDto kakaoUserInfoDto, String kakaoOauthToken) { - return new FirstAccessResponse( - false, - kakaoUserInfoDto.kakaoAccountDto().profile().nickname(), - kakaoUserInfoDto.kakaoAccountDto().email(), - kakaoUserInfoDto.kakaoAccountDto().profile().profileImageUrl(), - kakaoOauthToken - ); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java deleted file mode 100644 index 4fcfc5576..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record KakaoCodeRequest( - String code) { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java deleted file mode 100644 index 1e2320e35..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public interface KakaoOauthResponse { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java new file mode 100644 index 000000000..6772cb2c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenDto( + @JsonProperty("id_token") String idToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java new file mode 100644 index 000000000..5c4363e51 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.auth.dto.oauth; + +/* +* 애플로부터 사용자의 정보를 받아올 때 사용한다. +* 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. +* 따라서 닉네임, 프로필 정보는 null 을 반환한다. +* */ +public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { + + @Override + public String getEmail() { + return email; + } + + @Override + public String getProfileImageUrl() { + return null; + } + + @Override + public String getNickname() { + return null; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java similarity index 85% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java index 767645e3b..6d4ccd10c 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java similarity index 61% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java index 85aea091d..fbd975b50 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java @@ -1,11 +1,11 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoUserInfoDto( - @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) { + @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) implements OAuthUserInfoDto { @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoAccountDto( @@ -16,6 +16,22 @@ public record KakaoAccountDto( public record KakaoProfileDto( @JsonProperty("profile_image_url") String profileImageUrl, String nickname) { + } } + + @Override + public String getEmail() { + return kakaoAccountDto.email; + } + + @Override + public String getProfileImageUrl() { + return kakaoAccountDto.profile.profileImageUrl; + } + + @Override + public String getNickname() { + return kakaoAccountDto.profile.nickname; + } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java new file mode 100644 index 000000000..abbdb7802 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.auth.dto.oauth; + +import jakarta.validation.constraints.NotBlank; + +public record OAuthCodeRequest( + + @NotBlank(message = "인증 코드를 입력해주세요.") + String code) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java new file mode 100644 index 000000000..ddbe121f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java new file mode 100644 index 000000000..8ad429876 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record OAuthSignInResponse( + boolean isRegistered, + String accessToken, + String refreshToken) implements OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java new file mode 100644 index 000000000..ed794851b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthUserInfoDto { + + String getEmail(); + + String getProfileImageUrl(); + + String getNickname(); +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java new file mode 100644 index 000000000..5a6c60c57 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record SignUpPrepareResponse( + boolean isRegistered, + String nickname, + String email, + String profileImageUrl, + String signUpToken) implements OAuthResponse { + + public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String signUpToken) { + return new SignUpPrepareResponse( + false, + oAuthUserInfoDto.getNickname(), + oAuthUserInfoDto.getEmail(), + oAuthUserInfoDto.getProfileImageUrl(), + signUpToken + ); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java index 8ca39eb62..820d2e573 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -1,72 +1,29 @@ package com.example.solidconnection.auth.service; -import com.example.solidconnection.auth.client.KakaoOAuthClient; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; -import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -@RequiredArgsConstructor @Service +@RequiredArgsConstructor public class SignInService { private final AuthTokenProvider authTokenProvider; - private final SignUpTokenProvider signUpTokenProvider; - private final SiteUserRepository siteUserRepository; - private final KakaoOAuthClient kakaoOAuthClient; - /* - * 카카오에서 받아온 사용자 정보에 있는 이메일을 통해 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. - * 기존 회원 : 로그인 - * - 우리 서비스의 탈퇴 회원 방침을 적용한다. (계정 복구 기간 안에 접속하면 탈퇴를 무효화) - * - 액세스 토큰과 리프레시 토큰을 발급한다. - * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 - * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. - * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'kakaoOauthToken' 을 발급해서 응답한다. - * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) - * */ @Transactional - public KakaoOauthResponse signIn(KakaoCodeRequest kakaoCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.processOauth(kakaoCodeRequest.code()); - String email = kakaoUserInfoDto.kakaoAccountDto().email(); - Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO); - - if (optionalSiteUser.isPresent()) { - SiteUser siteUser = optionalSiteUser.get(); - resetQuitedAt(siteUser); - return getSignInInfo(siteUser); - } - - return getFirstAccessInfo(kakaoUserInfoDto); + public SignInResponse signIn(SiteUser siteUser) { + resetQuitedAt(siteUser); + String accessToken = authTokenProvider.generateAccessToken(siteUser); + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + return new SignInResponse(accessToken, refreshToken); } - // 계적 복구 기한이 지난 회원은 자정마다 삭제된다. (UserRemovalScheduler 참고) - // 따라서 DB 에서 조회되었다면 아직 기한이 지나지 않았다는 뜻이므로, 탈퇴 날짜를 초기화한다. private void resetQuitedAt(SiteUser siteUser) { if (siteUser.getQuitedAt() == null) { return; } - siteUser.setQuitedAt(null); } - - private SignInResponse getSignInInfo(SiteUser siteUser) { - String accessToken = authTokenProvider.generateAccessToken(siteUser); - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); - return new SignInResponse(true, accessToken, refreshToken); - } - - private FirstAccessResponse getFirstAccessInfo(KakaoUserInfoDto kakaoUserInfoDto) { - String kakaoOauthToken = signUpTokenProvider.generateAndSaveSignUpToken(kakaoUserInfoDto.kakaoAccountDto().email()); - return FirstAccessResponse.of(kakaoUserInfoDto, kakaoOauthToken); - } } diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java deleted file mode 100644 index f04bf112b..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpTokenProvider.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@Component -public class SignUpTokenProvider extends TokenProvider { - - public SignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { - super(jwtProperties, redisTemplate); - } - - public String generateAndSaveSignUpToken(String email) { - String signUpToken = generateToken(email, TokenType.SIGN_UP); - return saveToken(signUpToken, TokenType.SIGN_UP); - } - - public Optional findSignUpToken(String email) { - String signUpKey = TokenType.SIGN_UP.addPrefix(email); - return Optional.ofNullable(redisTemplate.opsForValue().get(signUpKey)); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java b/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java deleted file mode 100644 index a87a4aa2c..000000000 --- a/src/main/java/com/example/solidconnection/auth/service/TokenValidator.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.custom.exception.CustomException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import java.util.Date; -import java.util.Objects; - -import static com.example.solidconnection.auth.domain.TokenType.ACCESS; -import static com.example.solidconnection.auth.domain.TokenType.REFRESH; -import static com.example.solidconnection.auth.domain.TokenType.SIGN_UP; -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.EMPTY_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; - -@Component -@RequiredArgsConstructor -public class TokenValidator { - - private final RedisTemplate redisTemplate; - - @Value("${jwt.secret}") - private String secretKey; - - public void validateAccessToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, ACCESS); - validateRefreshToken(token); - } - - public void validateKakaoToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, SIGN_UP); - validateKakaoTokenNotUsed(token); - } - - private void validateTokenNotEmpty(String token) { - if (!StringUtils.hasText(token)) { - throw new CustomException(EMPTY_TOKEN); - } - } - - private void validateTokenNotExpired(String token, TokenType tokenType) { - Date expiration = getClaim(token).getExpiration(); - long now = new Date().getTime(); - if ((expiration.getTime() - now) < 0) { - if (tokenType.equals(ACCESS)) { - throw new CustomException(ACCESS_TOKEN_EXPIRED); - } - if (token.equals(SIGN_UP)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - } - - private void validateRefreshToken(String token) { - String email = getClaim(token).getSubject(); - if (redisTemplate.opsForValue().get(REFRESH.addPrefix(email)) == null) { - throw new CustomException(REFRESH_TOKEN_EXPIRED); - } - } - - private void validateKakaoTokenNotUsed(String token) { - String email = getClaim(token).getSubject(); - if (!Objects.equals(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email)), token)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - - private Claims getClaim(String token) { - return Jwts.parser() - .setSigningKey(this.secretKey) - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java new file mode 100644 index 000000000..2af82e07d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.AppleOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class AppleOAuthService extends OAuthService { + + private final AppleOAuthClient appleOAuthClient; + + public AppleOAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, + AppleOAuthClient appleOAuthClient, SignInService signInService) { + super(signUpTokenProvider, siteUserRepository, signInService); + this.appleOAuthClient = appleOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return appleOAuthClient.processOAuth(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.APPLE; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java new file mode 100644 index 000000000..5dc6faea1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.KakaoOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class KakaoOAuthService extends OAuthService { + + private final KakaoOAuthClient kakaoOAuthClient; + + public KakaoOAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, + KakaoOAuthClient kakaoOAuthClient, SignInService signInService) { + super(signUpTokenProvider, siteUserRepository, signInService); + this.kakaoOAuthClient = kakaoOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return kakaoOAuthClient.getUserInfo(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.KAKAO; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java new file mode 100644 index 000000000..4f37db060 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.auth.service.oauth; + + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/* + * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. + * 기존 회원 : 로그인한다. + * 신규 회원 : 회원가입할 때 필요한 정보를 제공한다. + * */ +public abstract class OAuthService { + + private final SignUpTokenProvider signUpTokenProvider; + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + + protected OAuthService(SignUpTokenProvider signUpTokenProvider, SiteUserRepository siteUserRepository, SignInService signInService) { + this.signUpTokenProvider = signUpTokenProvider; + this.siteUserRepository = siteUserRepository; + this.signInService = signInService; + } + + @Transactional + public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { + OAuthUserInfoDto userInfoDto = getOAuthUserInfo(oauthCodeRequest.code()); + String email = userInfoDto.getEmail(); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, getAuthType()); + + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + return getSignInResponse(siteUser); + } + + return getSignUpPrepareResponse(userInfoDto); + } + + protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { + SignInResponse signInResponse = signInService.signIn(siteUser); + return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + } + + protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), getAuthType()); + return SignUpPrepareResponse.of(userInfoDto, signUpToken); + } + + protected abstract OAuthUserInfoDto getOAuthUserInfo(String code); + protected abstract AuthType getAuthType(); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java similarity index 62% rename from src/main/java/com/example/solidconnection/auth/service/SignUpService.java rename to src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java index 788b07e44..7b6d44d26 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java @@ -1,7 +1,8 @@ -package com.example.solidconnection.auth.service; +package com.example.solidconnection.auth.service.oauth; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; +import com.example.solidconnection.auth.service.SignInService; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.entity.InterestedCountry; import com.example.solidconnection.entity.InterestedRegion; @@ -12,7 +13,6 @@ import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Role; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,10 +24,10 @@ @RequiredArgsConstructor @Service -public class SignUpService { +public class OAuthSignUpService { - private final TokenValidator tokenValidator; - private final AuthTokenProvider authTokenProvider; + private final SignUpTokenProvider signUpTokenProvider; + private final SignInService signInService; private final SiteUserRepository siteUserRepository; private final RegionRepository regionRepository; private final InterestedRegionRepository interestedRegionRepository; @@ -35,43 +35,31 @@ public class SignUpService { private final InterestedCountyRepository interestedCountyRepository; /* - * 회원가입을 한다. - * - 카카오로 최초 로그인 시 우리 서비스에서 발급한 카카오 토큰 kakaoOauthToken 을 검증한다. - * - 이는 '카카오 인증을 하지 않고 회원가입 api 만으로 회원가입 하는 상황'을 방지하기 위함이다. - * - 만약 api 만으로 회원가입을 한다면, 카카오 인증과 이메일에 대한 검증 없이 회원가입이 가능해진다. - * - 이메일은 우리 서비스에서 사용자를 식별하는 중요한 정보이기 때문에 '우리 서비스에서 발급한 카카오 토큰인지 검증하는' 단계가 필요하다. + * OAuth 인증 후 회원가입을 한다. + * - 우리 서버에서 OAuth 인증했음을 확인하기 위한 signUpToken 을 검증한다. * - 사용자 정보를 DB에 저장한다. * - 관심 국가와 지역을 DB에 저장한다. * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. * */ - // todo: 여러가지 가입 방법 적용해야 함 @Transactional - public SignUpResponse signUp(SignUpRequest signUpRequest) { + public SignInResponse signUp(SignUpRequest signUpRequest) { // 검증 - tokenValidator.validateKakaoToken(signUpRequest.kakaoOauthToken()); - String email = authTokenProvider.getEmail(signUpRequest.kakaoOauthToken()); + signUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); validateNicknameDuplicated(signUpRequest.nickname()); - validateUserNotDuplicated(email); + String email = signUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = signUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + validateUserNotDuplicated(email, authType); // 사용자 저장 - SiteUser siteUser = signUpRequest.toSiteUser(email, Role.MENTEE); - SiteUser savedSiteUser = siteUserRepository.save(siteUser); + SiteUser siteUser = siteUserRepository.save(signUpRequest.toSiteUser(email, authType)); // 관심 지역, 국가 저장 - saveInterestedRegion(signUpRequest, savedSiteUser); - saveInterestedCountry(signUpRequest, savedSiteUser); + saveInterestedRegion(signUpRequest, siteUser); + saveInterestedCountry(signUpRequest, siteUser); - // 토큰 발급 - String accessToken = authTokenProvider.generateAccessToken(siteUser); - String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); - return new SignUpResponse(accessToken, refreshToken); - } - - private void validateUserNotDuplicated(String email) { - if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.KAKAO)) { - throw new CustomException(USER_ALREADY_EXISTED); - } + // 로그인 + return signInService.signIn(siteUser); } private void validateNicknameDuplicated(String nickname) { @@ -80,6 +68,12 @@ private void validateNicknameDuplicated(String nickname) { } } + private void validateUserNotDuplicated(String email, AuthType authType) { + if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + private void saveInterestedRegion(SignUpRequest signUpRequest, SiteUser savedSiteUser) { List interestedRegionNames = signUpRequest.interestedRegions(); List interestedRegions = regionRepository.findByKoreanNames(interestedRegionNames).stream() diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java new file mode 100644 index 000000000..5399dc1eb --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProvider.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +public class SignUpTokenProvider extends TokenProvider { + + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + public SignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + super(jwtProperties, redisTemplate); + } + + public String generateAndSaveSignUpToken(String email, AuthType authType) { + Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); + Claims claims = Jwts.claims(authTypeClaim).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + return saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + AuthType.valueOf(serializedAuthType); + } catch (Exception e) { + throw new CustomException(OAUTH_SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public AuthType parseAuthType(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); + } +} diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java new file mode 100644 index 000000000..609e9ee89 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.apple") +public record AppleOAuthClientProperties( + String tokenUrl, + String clientSecretAudienceUrl, + String redirectUrl, + String publicKeyUrl, + String clientId, + String teamId, + String keyId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java new file mode 100644 index 000000000..73b196d76 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoOAuthClientProperties( + String tokenUrl, + String userInfoUrl, + String redirectUrl, + String clientId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java similarity index 91% rename from src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java rename to src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java index 51f7205be..36ce3f67b 100644 --- a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.rest; +package com.example.solidconnection.config.client; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index 8c3032284..d3fdf136d 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -11,6 +11,16 @@ @AllArgsConstructor public enum ErrorCode { + // apple + APPLE_AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST.value(), "애플 인증에 실패했습니다."), + APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 이메일이 없습니다."), + APPLE_ID_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 이 만료되었습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 애플 idToken 입니다."), + APPLE_ID_TOKEN_MALFORMED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 의 형식이 잘못되었습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "idToken 를 서명한 애플 공개키를 찾을 수 없습니다"), + FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), + // kakao KAKAO_REDIRECT_URI_MISMATCH(HttpStatus.BAD_REQUEST.value(), "리다이렉트 uri가 잘못되었습니다."), INVALID_OR_EXPIRED_KAKAO_AUTH_CODE(HttpStatus.BAD_REQUEST.value(), "사용할 수 없는 카카오 인증 코드입니다. 카카오 인증 코드는 일회용이며, 인증 만료 시간은 10분입니다."), @@ -18,6 +28,10 @@ public enum ErrorCode { KAKAO_USER_INFO_FAIL(HttpStatus.BAD_REQUEST.value(), "카카오 사용자 정보 조회에 실패했습니다."), INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN(HttpStatus.BAD_REQUEST.value(), "우리 서비스에서 발급한 카카오 토큰이 아닙니다"), + // oauth + OAUTH_SIGN_UP_TOKEN_INVALID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 회원가입 토큰입니다."), + OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER(HttpStatus.BAD_REQUEST.value(), "회원가입 토큰이 우리 서버에서 발급되지 않았습니다."), + // data not found UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java index 3a1b58520..d3ea8fed9 100644 --- a/src/main/java/com/example/solidconnection/util/JwtUtils.java +++ b/src/main/java/com/example/solidconnection/util/JwtUtils.java @@ -1,6 +1,7 @@ package com.example.solidconnection.util; import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import jakarta.servlet.http.HttpServletRequest; @@ -29,7 +30,7 @@ public static String parseTokenFromRequest(HttpServletRequest request) { public static String parseSubjectIgnoringExpiration(String token, String secretKey) { try { - return extractSubject(token, secretKey); + return parseClaims(token, secretKey).getSubject(); } catch (ExpiredJwtException e) { return e.getClaims().getSubject(); } catch (Exception e) { @@ -39,7 +40,7 @@ public static String parseSubjectIgnoringExpiration(String token, String secretK public static String parseSubject(String token, String secretKey) { try { - return extractSubject(token, secretKey); + return parseClaims(token, secretKey).getSubject(); } catch (Exception e) { throw new CustomException(INVALID_TOKEN); } @@ -58,11 +59,10 @@ public static boolean isExpired(String token, String secretKey) { } } - private static String extractSubject(String token, String secretKey) throws ExpiredJwtException { + public static Claims parseClaims(String token, String secretKey) throws ExpiredJwtException { return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) - .getBody() - .getSubject(); + .getBody(); } } diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java new file mode 100644 index 000000000..b80c4ca5d --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("로그인 서비스 테스트") +@TestContainerSpringBootTest +class SignInServiceTest { + + @Autowired + private SignInService signInService; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Test + void 성공적으로_로그인한다() { + // when + SignInResponse signInResponse = signInService.signIn(siteUser); + + // then + String accessTokenSubject = JwtUtils.parseSubject(signInResponse.accessToken(), jwtProperties.secret()); + String refreshTokenSubject = JwtUtils.parseSubject(signInResponse.refreshToken(), jwtProperties.secret()); + Optional savedRefreshToken = authTokenProvider.findRefreshToken(subject); + assertAll( + () -> assertThat(accessTokenSubject).isEqualTo(subject), + () -> assertThat(refreshTokenSubject).isEqualTo(subject), + () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); + } + + @Test + void 탈퇴한_이력이_있으면_초기화한다() { + // given + siteUser.setQuitedAt(LocalDate.now().minusDays(1)); + siteUserRepository.save(siteUser); + + // when + signInService.signIn(siteUser); + + // then + assertThat(siteUser.getQuitedAt()).isNull(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java deleted file mode 100644 index 382008d8c..000000000 --- a/src/test/java/com/example/solidconnection/auth/service/SignUpTokenProviderTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.solidconnection.auth.service; - -import com.example.solidconnection.auth.domain.TokenType; -import com.example.solidconnection.config.security.JwtProperties; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import com.example.solidconnection.util.JwtUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@TestContainerSpringBootTest -@DisplayName("회원가입 토큰 제공자 테스트") -class SignUpTokenProviderTest { - - @Autowired - private SignUpTokenProvider signUpTokenProvider; - - @Autowired - private RedisTemplate redisTemplate; - - @Autowired - private JwtProperties jwtProperties; - - @Test - void 회원가입_토큰을_생성하고_저장한다() { - // when - String email = "email"; - String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email); - - // then - String actualSubject = JwtUtils.parseSubject(signUpToken, jwtProperties.secret()); - String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); - assertAll( - () -> assertThat(actualSubject).isEqualTo(email), - () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) - ); - } - - @Test - void 저장된_회원가입_토큰을_조회한다() { - // given - String email = "email"; - String signUpToken = "token"; - redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), signUpToken); - - // when - Optional actualSignUpToken = signUpTokenProvider.findSignUpToken(email); - - // then - assertThat(actualSignUpToken).hasValue(signUpToken); - } - - @Test - void 저장되지_않은_회원가입_토큰을_조회한다() { - // given - String email = "email"; - - // when - Optional actualSignUpToken = signUpTokenProvider.findSignUpToken(email); - - // then - assertThat(actualSignUpToken).isEmpty(); - } -} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java new file mode 100644 index 000000000..d3a1efac1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/SignUpTokenProviderTest.java @@ -0,0 +1,182 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static com.example.solidconnection.auth.service.oauth.SignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("회원가입 토큰 제공자 테스트") +class SignUpTokenProviderTest { + + @Autowired + private SignUpTokenProvider signUpTokenProvider; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 회원가입_토큰을_생성하고_저장한다() { + // given + String email = "email"; + AuthType authType = AuthType.KAKAO; + + // when + String signUpToken = signUpTokenProvider.generateAndSaveSignUpToken(email, authType); + + // then + Claims claims = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()); + String actualSubject = claims.getSubject(); + AuthType actualAuthType = AuthType.valueOf(claims.get(AUTH_TYPE_CLAIM_KEY, String.class)); + String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + assertAll( + () -> assertThat(actualSubject).isEqualTo(email), + () -> assertThat(actualAuthType).isEqualTo(authType), + () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) + ); + } + + @Nested + class 주어진_회원가입_토큰을_검증한다 { + + @Test + void 검증_성공한다() { + // given + String email = "email@test.com"; + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); + } + + @Test + void 만료되었으면_예외_응답을_반환한다() { + // given + String expiredToken = createExpiredToken(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(expiredToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_jwt_가_아닌_토큰() { + // given + String notJwt = "not jwt"; + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(notJwt)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_authType_클래스_불일치() { + // given + Map wrongClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, "카카오")); + String wrongAuthType = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(wrongAuthType)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_subject_누락() { + // given + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(noSubject)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 우리_서버에_발급된_토큰이_아니면_예외_응답을_반환한다() { + // given + Map validClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject("email").compact(); + + // when & then + assertThatCode(() -> signUpTokenProvider.validateSignUpToken(signUpToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(OAUTH_SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER.getMessage()); + } + } + + @Test + void 회원가입_토큰에서_이메일을_추출한다() { + // given + String email = "email@test.com"; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when + String extractedEmail = signUpTokenProvider.parseEmail(validToken); + + // then + assertThat(extractedEmail).isEqualTo(email); + } + + @Test + void 회원가입_토큰에서_인증_타입을_추출한다() { + // given + AuthType authType = AuthType.APPLE; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, authType); + String validToken = createBaseJwtBuilder().setSubject("email").addClaims(claim).compact(); + + // when + AuthType extractedAuthType = signUpTokenProvider.parseAuthType(validToken); + + // then + assertThat(extractedAuthType).isEqualTo(authType); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private JwtBuilder createBaseJwtBuilder() { + return Jwts.builder() + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()); + } +} diff --git a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java index a549b62a2..09c97d46e 100644 --- a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java +++ b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java @@ -2,7 +2,7 @@ import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.LanguageTestType; diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index 8d3ddc75f..cc16f71c1 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -1,10 +1,10 @@ package com.example.solidconnection.e2e; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import io.restassured.RestAssured; @@ -45,18 +45,18 @@ class SignInTest extends BaseEndToEndTest { String kakaoCode = "kakaoCode"; String email = "email@email.com"; KakaoUserInfoDto kakaoUserInfoDto = createKakaoUserInfoDtoByEmail(email); - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(kakaoUserInfoDto); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - FirstAccessResponse response = RestAssured.given().log().all() + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + SignUpPrepareResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(FirstAccessResponse.class); + .extract().as(SignUpPrepareResponse.class); KakaoUserInfoDto.KakaoAccountDto.KakaoProfileDto kakaoProfileDto = kakaoUserInfoDto.kakaoAccountDto().profile(); assertAll("카카오톡 사용자 정보를 응답한다.", @@ -64,10 +64,10 @@ class SignInTest extends BaseEndToEndTest { () -> assertThat(response.email()).isEqualTo(email), () -> assertThat(response.nickname()).isEqualTo(kakaoProfileDto.nickname()), () -> assertThat(response.profileImageUrl()).isEqualTo(kakaoProfileDto.profileImageUrl()), - () -> assertThat(response.kakaoOauthToken()).isNotNull()); + () -> assertThat(response.signUpToken()).isNotNull()); assertThat(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email))) .as("카카오 인증 토큰을 저장한다.") - .isEqualTo(response.kakaoOauthToken()); + .isEqualTo(response.signUpToken()); } @Test @@ -75,21 +75,21 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 사용자 정보 저장 SiteUser siteUser = siteUserRepository.save(createSiteUserByEmail(email)); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthCodeRequest oAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(oAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); assertAll("리프레스 토큰과 엑세스 토큰을 응답한다.", () -> assertThat(response.isRegistered()).isTrue(), @@ -105,7 +105,7 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 계정 복구 기간이 되지 않은 사용자 저장 @@ -115,14 +115,14 @@ class SignInTest extends BaseEndToEndTest { SiteUser siteUser = siteUserRepository.save(siteUserFixture); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); SiteUser updatedSiteUser = siteUserRepository.findById(siteUser.getId()).get(); assertAll("리프레스 토큰과 엑세스 토큰을 응답하고, 탈퇴 날짜를 초기화한다.", diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java index 1bbe150a8..7d01f365c 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -1,9 +1,9 @@ package com.example.solidconnection.e2e; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; import com.example.solidconnection.auth.service.AuthTokenProvider; -import com.example.solidconnection.auth.service.SignUpTokenProvider; +import com.example.solidconnection.auth.service.oauth.SignUpTokenProvider; import com.example.solidconnection.custom.response.ErrorResponse; import com.example.solidconnection.entity.Country; import com.example.solidconnection.entity.InterestedCountry; @@ -29,8 +29,8 @@ import java.util.List; import static com.example.solidconnection.auth.domain.TokenType.REFRESH; -import static com.example.solidconnection.custom.exception.ErrorCode.JWT_EXCEPTION; import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.OAUTH_SIGN_UP_TOKEN_INVALID; import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByNickName; @@ -74,20 +74,20 @@ class SignUpTest extends BaseEndToEndTest { // setup - 카카오 토큰 발급 String email = "email@email.com"; - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 List interestedRegionNames = List.of("유럽"); List interestedCountryNames = List.of("프랑스", "독일"); SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, interestedRegionNames, interestedCountryNames, PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname", "2000-01-01"); - SignUpResponse response = RestAssured.given().log().all() + SignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(signUpRequest) .when().post("/auth/sign-up") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignUpResponse.class); + .extract().as(SignInResponse.class); SiteUser savedSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO).get(); assertAll( @@ -126,7 +126,7 @@ class SignUpTest extends BaseEndToEndTest { // setup - 카카오 토큰 발급 String email = "email@email.com"; - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, @@ -151,7 +151,7 @@ class SignUpTest extends BaseEndToEndTest { siteUserRepository.save(alreadyExistUser); // setup - 카카오 토큰 발급 - String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail); + String generatedKakaoToken = signUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, @@ -181,6 +181,6 @@ class SignUpTest extends BaseEndToEndTest { .extract().as(ErrorResponse.class); assertThat(errorResponse.message()) - .contains(JWT_EXCEPTION.getMessage()); + .contains(OAUTH_SIGN_UP_TOKEN_INVALID.getMessage()); } }