From dc4f73ad7793226f2b89e1878c42cccecfd1f0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A4=80?= Date: Thu, 16 Jul 2026 10:40:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/KakaoUserInfoClient.java | 40 +++++++ .../auth/client/KakaoUserInfoResponse.java | 43 +++++++ .../auth/controller/AuthController.java | 13 +- .../backend/domain/auth/dto/AuthReqDTO.java | 34 ++++-- .../backend/domain/auth/dto/AuthResDTO.java | 24 ++++ .../auth/dto/SocialSignupTokenPayload.java | 22 ++++ .../domain/auth/dto/SocialUserInfo.java | 12 ++ .../domain/auth/exception/AuthErrorCode.java | 39 ++++++ .../repository/SocialAccountRepository.java | 11 ++ .../domain/auth/service/AuthService.java | 112 +++++++++++++++++- .../domain/auth/service/JwtTokenProvider.java | 84 +++++++++++++ .../service/SocialSignupTokenService.java | 100 ++++++++++++++++ .../user/controller/UserController.java | 2 + .../domain/user/exception/UserErrorCode.java | 21 ++++ .../user/repository/UserRepository.java | 4 +- src/main/resources/application.yml | 11 +- 16 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoClient.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoResponse.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/dto/SocialSignupTokenPayload.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/dto/SocialUserInfo.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/service/JwtTokenProvider.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java create mode 100644 src/main/java/com/yeogido/backend/domain/user/exception/UserErrorCode.java diff --git a/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoClient.java b/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoClient.java new file mode 100644 index 0000000..e340898 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoClient.java @@ -0,0 +1,40 @@ +package com.yeogido.backend.domain.auth.client; + +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.exception.AuthErrorCode; +import com.yeogido.backend.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Component +@RequiredArgsConstructor +public class KakaoUserInfoClient { + + private final RestClient.Builder restClientBuilder; + + @Value("${app.oauth.kakao.user-info-uri}") + private String userInfoUri; + + public SocialUserInfo getUserInfo(String accessToken) { + try { + KakaoUserInfoResponse response = restClientBuilder.build() + .get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(KakaoUserInfoResponse.class); + + if (response == null || response.id() == null) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_ACCESS_TOKEN); + } + + return response.toSocialUserInfo(); + } catch (RestClientException e) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_ACCESS_TOKEN); + } + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoResponse.java b/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoResponse.java new file mode 100644 index 0000000..ff29500 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/client/KakaoUserInfoResponse.java @@ -0,0 +1,43 @@ +package com.yeogido.backend.domain.auth.client; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.enums.SocialProvider; + +public record KakaoUserInfoResponse( + Long id, + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount, + Properties properties +) { + + public SocialUserInfo toSocialUserInfo() { + String email = kakaoAccount == null ? null : kakaoAccount.email(); + String name = kakaoAccount == null ? null : kakaoAccount.name(); + if (name == null && properties != null) { + name = properties.nickname(); + } + String profileImageUrl = properties == null ? null : properties.profileImage(); + + return new SocialUserInfo( + SocialProvider.KAKAO, + String.valueOf(id), + email, + name, + profileImageUrl + ); + } + + public record KakaoAccount( + String email, + String name + ) { + } + + public record Properties( + String nickname, + @JsonProperty("profile_image") + String profileImage + ) { + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/controller/AuthController.java b/src/main/java/com/yeogido/backend/domain/auth/controller/AuthController.java index ccda9ba..c254d84 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/controller/AuthController.java +++ b/src/main/java/com/yeogido/backend/domain/auth/controller/AuthController.java @@ -37,10 +37,17 @@ public ApiResponse login(@Valid @RequestBody AuthReqDTO.Login return ApiResponse.onSuccess(SuccessCode.OK, response); } - @Operation(summary = "소셜 로그인 API", description = "소셜 제공자와 제공자 식별자를 이용하여 로그인 또는 자동 회원가입을 진행합니다.") + @Operation(summary = "소셜 로그인 API", description = "프론트에서 전달받은 소셜 accessToken을 검증하고, 신규 사용자는 프로필 작성 단계로 안내합니다.") @PostMapping("/social-login") - public ApiResponse socialLogin(@Valid @RequestBody AuthReqDTO.SocialLogin request) { - AuthResDTO.Token response = authService.socialLogin(request); + public ApiResponse socialLogin(@Valid @RequestBody AuthReqDTO.SocialLogin request) { + AuthResDTO.SocialLogin response = authService.socialLogin(request); + return ApiResponse.onSuccess(SuccessCode.OK, response); + } + + @Operation(summary = "소셜 로그인 추가 프로필 작성 완료 API", description = "신규 소셜 사용자의 추가 프로필 정보를 저장하고 회원가입을 완료합니다.") + @PostMapping("/social-signup/complete") + public ApiResponse completeSocialSignup(@Valid @RequestBody AuthReqDTO.SocialSignupComplete request) { + AuthResDTO.Token response = authService.completeSocialSignup(request); return ApiResponse.onSuccess(SuccessCode.OK, response); } diff --git a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java index 9d0cf9b..f1c4d50 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java +++ b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java @@ -1,6 +1,7 @@ package com.yeogido.backend.domain.auth.dto; import com.yeogido.backend.domain.auth.enums.SocialProvider; +import com.yeogido.backend.domain.user.enums.Gender; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -55,14 +56,33 @@ public record SocialLogin( @NotNull(message = "소셜 로그인 제공자는 필수 입력값입니다.") SocialProvider provider, - @Schema(description = "소셜 제공자 사용자 식별자", example = "1234567890") - @NotBlank(message = "소셜 제공자 사용자 식별자는 필수 입력값입니다.") - String providerId, + @Schema(description = "소셜 accessToken", example = "kakao_access_token") + @NotBlank(message = "소셜 accessToken은 필수 입력값입니다.") + String accessToken + ) {} - @Schema(description = "소셜 계정 이메일", example = "kakao_user@example.com") - @NotBlank(message = "이메일은 필수 입력값입니다.") - @Email(message = "올바른 이메일 형식이어야 합니다.") - String email + @Schema(name = "AuthSocialSignupCompleteReq", description = "소셜 로그인 추가 프로필 작성 완료 요청") + public record SocialSignupComplete( + @Schema(description = "소셜 회원가입 임시 토큰", example = "temp_social_signup_token") + @NotBlank(message = "소셜 회원가입 임시 토큰은 필수 입력값입니다.") + String temporaryToken, + + @Schema(description = "사용자 이름. 소셜 계정 이름을 기본값으로 사용하며 프로필에서 수정 가능합니다.", example = "홍길동") + @NotBlank(message = "이름은 필수 입력값입니다.") + String name, + + @Schema(description = "성별", example = "MALE") + @NotNull(message = "성별은 필수 입력값입니다.") + Gender gender, + + @Schema(description = "출생연도", example = "2001") + @NotBlank(message = "출생연도는 필수 입력값입니다.") + @Pattern(regexp = "^\\d{4}$", message = "출생연도는 4자리 숫자여야 합니다.") + String birthYear, + + @Schema(description = "지역 ID", example = "1") + @NotNull(message = "지역 ID는 필수 입력값입니다.") + Long regionId ) {} @Schema(name = "AuthPasswordSendCodeReq", description = "비밀번호 찾기 인증번호 발송 요청") diff --git a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthResDTO.java b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthResDTO.java index e7c9eb1..ecdf6c0 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthResDTO.java +++ b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthResDTO.java @@ -22,6 +22,30 @@ public record Token( String refreshToken ) {} + @Schema(name = "AuthSocialLoginRes", description = "소셜 로그인 응답") + public record SocialLogin( + @Schema(description = "신규 소셜 사용자 여부", example = "true") + boolean isNewUser, + + @Schema(description = "사용자 ID. 기존 사용자 로그인 성공 시 반환", example = "2") + Long userId, + + @Schema(description = "액세스 토큰. 기존 사용자 로그인 성공 시 반환", example = "eyJhbGciOiJIUzI1NiJ9...") + String accessToken, + + @Schema(description = "리프레시 토큰. 기존 사용자 로그인 성공 시 반환", example = "eyJhbGciOiJIUzI1NiJ9...") + String refreshToken, + + @Schema(description = "프로필 작성용 임시 토큰. 신규 소셜 사용자일 때 반환", example = "temp_social_signup_token") + String temporaryToken, + + @Schema(description = "소셜 Provider에서 조회한 이메일. 신규 소셜 사용자일 때 반환", example = "kakao_user@example.com") + String email, + + @Schema(description = "소셜 Provider에서 조회한 이름. 신규 소셜 사용자일 때 반환", example = "홍길동") + String name + ) {} + @Schema(name = "AuthEmailCheckRes", description = "이메일 중복 확인 응답") public record EmailCheck( @Schema(description = "이메일 사용 가능 여부", example = "true") diff --git a/src/main/java/com/yeogido/backend/domain/auth/dto/SocialSignupTokenPayload.java b/src/main/java/com/yeogido/backend/domain/auth/dto/SocialSignupTokenPayload.java new file mode 100644 index 0000000..cb8745d --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/dto/SocialSignupTokenPayload.java @@ -0,0 +1,22 @@ +package com.yeogido.backend.domain.auth.dto; + +import com.yeogido.backend.domain.auth.enums.SocialProvider; + +public record SocialSignupTokenPayload( + SocialProvider provider, + String providerId, + String email, + String name, + String profileImageUrl +) { + + public static SocialSignupTokenPayload from(SocialUserInfo socialUserInfo) { + return new SocialSignupTokenPayload( + socialUserInfo.provider(), + socialUserInfo.providerId(), + socialUserInfo.email(), + socialUserInfo.name(), + socialUserInfo.profileImageUrl() + ); + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/dto/SocialUserInfo.java b/src/main/java/com/yeogido/backend/domain/auth/dto/SocialUserInfo.java new file mode 100644 index 0000000..f1d6bf7 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/dto/SocialUserInfo.java @@ -0,0 +1,12 @@ +package com.yeogido.backend.domain.auth.dto; + +import com.yeogido.backend.domain.auth.enums.SocialProvider; + +public record SocialUserInfo( + SocialProvider provider, + String providerId, + String email, + String name, + String profileImageUrl +) { +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..27aec7b --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,39 @@ +package com.yeogido.backend.domain.auth.exception; + +import com.yeogido.backend.global.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + UNSUPPORTED_SOCIAL_PROVIDER( + HttpStatus.BAD_REQUEST, + "EXT4001", + "지원하지 않는 소셜 플랫폼입니다." + ), + + SOCIAL_EMAIL_REQUIRED( + HttpStatus.BAD_REQUEST, + "AUTH4002", + "소셜 계정 이메일 제공 동의가 필요합니다." + ), + + INVALID_SOCIAL_ACCESS_TOKEN( + HttpStatus.UNAUTHORIZED, + "AUTH4011", + "유효하지 않은 소셜 accessToken입니다." + ), + + INVALID_SOCIAL_SIGNUP_TOKEN( + HttpStatus.BAD_REQUEST, + "AUTH4003", + "유효하지 않거나 만료된 소셜 회원가입 임시 토큰입니다." + ); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java b/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java new file mode 100644 index 0000000..18bb249 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java @@ -0,0 +1,11 @@ +package com.yeogido.backend.domain.auth.repository; + +import com.yeogido.backend.domain.auth.entity.SocialAccount; +import com.yeogido.backend.domain.auth.enums.SocialProvider; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SocialAccountRepository extends JpaRepository { + + Optional findByProviderAndProviderId(SocialProvider provider, String providerId); +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java index d65be6e..150e38f 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java @@ -1,12 +1,39 @@ package com.yeogido.backend.domain.auth.service; +import com.yeogido.backend.domain.auth.client.KakaoUserInfoClient; import com.yeogido.backend.domain.auth.dto.AuthReqDTO; import com.yeogido.backend.domain.auth.dto.AuthResDTO; +import com.yeogido.backend.domain.auth.dto.SocialSignupTokenPayload; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.entity.SocialAccount; +import com.yeogido.backend.domain.auth.enums.SocialProvider; +import com.yeogido.backend.domain.auth.exception.AuthErrorCode; +import com.yeogido.backend.domain.auth.repository.SocialAccountRepository; +import com.yeogido.backend.domain.region.entity.Region; +import com.yeogido.backend.domain.region.exception.RegionErrorCode; +import com.yeogido.backend.domain.region.repository.RegionRepository; +import com.yeogido.backend.domain.user.entity.User; +import com.yeogido.backend.domain.user.enums.UserRole; +import com.yeogido.backend.domain.user.enums.UserStatus; +import com.yeogido.backend.domain.user.exception.UserErrorCode; +import com.yeogido.backend.domain.user.repository.UserRepository; +import com.yeogido.backend.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; @Service +@RequiredArgsConstructor public class AuthService { + private final KakaoUserInfoClient kakaoUserInfoClient; + private final SocialAccountRepository socialAccountRepository; + private final SocialSignupTokenService socialSignupTokenService; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final RegionRepository regionRepository; + public AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request) { return new AuthResDTO.SignUp(1L); } @@ -19,14 +46,89 @@ public AuthResDTO.Token login(AuthReqDTO.Login request) { ); } - public AuthResDTO.Token socialLogin(AuthReqDTO.SocialLogin request) { - return new AuthResDTO.Token( - 2L, - "mock_social_access_token", - "mock_social_refresh_token" + @Transactional(readOnly = true) + public AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request) { + if (request.provider() != SocialProvider.KAKAO) { + throw new GeneralException(AuthErrorCode.UNSUPPORTED_SOCIAL_PROVIDER); + } + + SocialUserInfo socialUserInfo = kakaoUserInfoClient.getUserInfo(request.accessToken()); + + if (!StringUtils.hasText(socialUserInfo.email())) { + throw new GeneralException(AuthErrorCode.SOCIAL_EMAIL_REQUIRED); + } + + return socialAccountRepository.findByProviderAndProviderId( + socialUserInfo.provider(), + socialUserInfo.providerId() + ) + .map(this::createExistingSocialLoginResponse) + .orElseGet(() -> createNewSocialLoginResponse(socialUserInfo)); + } + + private AuthResDTO.SocialLogin createExistingSocialLoginResponse(SocialAccount socialAccount) { + User user = socialAccount.getUser(); + AuthResDTO.Token token = jwtTokenProvider.issueToken(user); + + return new AuthResDTO.SocialLogin( + false, + token.userId(), + token.accessToken(), + token.refreshToken(), + null, + null, + null + ); + } + + private AuthResDTO.SocialLogin createNewSocialLoginResponse(SocialUserInfo socialUserInfo) { + String temporaryToken = socialSignupTokenService.issueToken(socialUserInfo); + + return new AuthResDTO.SocialLogin( + true, + null, + null, + null, + temporaryToken, + socialUserInfo.email(), + socialUserInfo.name() ); } + @Transactional + public AuthResDTO.Token completeSocialSignup(AuthReqDTO.SocialSignupComplete request) { + SocialSignupTokenPayload payload = socialSignupTokenService.getPayload(request.temporaryToken()); + + if (userRepository.existsByEmail(payload.email())) { + throw new GeneralException(UserErrorCode.EMAIL_DUPLICATED); + } + + Region region = regionRepository.findById(request.regionId()) + .orElseThrow(() -> new GeneralException(RegionErrorCode.REGION_NOT_FOUND)); + + User user = userRepository.save(User.builder() + .nickname(request.name()) + .email(payload.email()) + .password(null) + .gender(request.gender()) + .birthYear(request.birthYear()) + .region(region) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .profileImage(payload.profileImageUrl()) + .build()); + + socialAccountRepository.save(SocialAccount.builder() + .user(user) + .provider(payload.provider()) + .providerId(payload.providerId()) + .build()); + + socialSignupTokenService.deleteToken(request.temporaryToken()); + + return jwtTokenProvider.issueToken(user); + } + public AuthResDTO.EmailCheck checkEmail(String email) { return new AuthResDTO.EmailCheck(true); } diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/JwtTokenProvider.java b/src/main/java/com/yeogido/backend/domain/auth/service/JwtTokenProvider.java new file mode 100644 index 0000000..5f5e823 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/service/JwtTokenProvider.java @@ -0,0 +1,84 @@ +package com.yeogido.backend.domain.auth.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yeogido.backend.domain.auth.dto.AuthResDTO; +import com.yeogido.backend.domain.user.entity.User; +import com.yeogido.backend.global.exception.GeneralErrorCode; +import com.yeogido.backend.global.exception.GeneralException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String TOKEN_TYPE_ACCESS = "ACCESS"; + private static final String TOKEN_TYPE_REFRESH = "REFRESH"; + + private final ObjectMapper objectMapper; + + @Value("${app.jwt.secret}") + private String jwtSecret; + + @Value("${app.jwt.access-token-expiration-minutes}") + private long accessTokenExpirationMinutes; + + @Value("${app.jwt.refresh-token-expiration-days}") + private long refreshTokenExpirationDays; + + public AuthResDTO.Token issueToken(User user) { + return new AuthResDTO.Token( + user.getId(), + createToken(user, TOKEN_TYPE_ACCESS, Instant.now().plus(accessTokenExpirationMinutes, ChronoUnit.MINUTES)), + createToken(user, TOKEN_TYPE_REFRESH, Instant.now().plus(refreshTokenExpirationDays, ChronoUnit.DAYS)) + ); + } + + private String createToken(User user, String tokenType, Instant expiresAt) { + try { + String header = encode(objectMapper.writeValueAsString(Map.of( + "alg", "HS256", + "typ", "JWT" + ))); + String payload = encode(objectMapper.writeValueAsString(Map.of( + "sub", String.valueOf(user.getId()), + "userId", user.getId(), + "role", user.getRole().name(), + "type", tokenType, + "iat", Instant.now().getEpochSecond(), + "exp", expiresAt.getEpochSecond() + ))); + String unsignedToken = header + "." + payload; + return unsignedToken + "." + sign(unsignedToken); + } catch (JsonProcessingException e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java b/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java new file mode 100644 index 0000000..0c1deef --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java @@ -0,0 +1,100 @@ +package com.yeogido.backend.domain.auth.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yeogido.backend.domain.auth.dto.SocialSignupTokenPayload; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.exception.AuthErrorCode; +import com.yeogido.backend.global.exception.GeneralErrorCode; +import com.yeogido.backend.global.exception.GeneralException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SocialSignupTokenService { + + private final ObjectMapper objectMapper; + + @Value("${app.oauth.social-signup-token-secret}") + private String tokenSecret; + + @Value("${app.oauth.social-signup-token-expiration-minutes}") + private long expirationMinutes; + + public String issueToken(SocialUserInfo socialUserInfo) { + SocialSignupToken token = new SocialSignupToken( + SocialSignupTokenPayload.from(socialUserInfo), + Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES).toEpochMilli() + ); + + try { + String payload = encode(objectMapper.writeValueAsString(token)); + String signature = sign(payload); + return payload + "." + signature; + } catch (JsonProcessingException e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); + } + } + + public SocialSignupTokenPayload getPayload(String temporaryToken) { + try { + String[] tokenParts = temporaryToken.split("\\."); + + if (tokenParts.length != 2 || !sign(tokenParts[0]).equals(tokenParts[1])) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); + } + + SocialSignupToken token = objectMapper.readValue( + decode(tokenParts[0]), + SocialSignupToken.class + ); + + if (token.expiresAt() < Instant.now().toEpochMilli()) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); + } + + return token.payload(); + } catch (JsonProcessingException | IllegalArgumentException e) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); + } + } + + public void deleteToken(String temporaryToken) { + } + + private String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private String decode(String value) { + return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); + } + + private String sign(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(tokenSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private record SocialSignupToken( + SocialSignupTokenPayload payload, + long expiresAt + ) { + } +} diff --git a/src/main/java/com/yeogido/backend/domain/user/controller/UserController.java b/src/main/java/com/yeogido/backend/domain/user/controller/UserController.java index 4c6128e..cb60c08 100644 --- a/src/main/java/com/yeogido/backend/domain/user/controller/UserController.java +++ b/src/main/java/com/yeogido/backend/domain/user/controller/UserController.java @@ -6,6 +6,7 @@ import com.yeogido.backend.global.common.response.ApiResponse; import com.yeogido.backend.global.common.response.CursorResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "사용자 API", description = "사용자 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") diff --git a/src/main/java/com/yeogido/backend/domain/user/exception/UserErrorCode.java b/src/main/java/com/yeogido/backend/domain/user/exception/UserErrorCode.java new file mode 100644 index 0000000..3958bb7 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/user/exception/UserErrorCode.java @@ -0,0 +1,21 @@ +package com.yeogido.backend.domain.user.exception; + +import com.yeogido.backend.global.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + EMAIL_DUPLICATED( + HttpStatus.CONFLICT, + "USER4091", + "이미 가입된 이메일입니다." + ); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/yeogido/backend/domain/user/repository/UserRepository.java b/src/main/java/com/yeogido/backend/domain/user/repository/UserRepository.java index 901fd03..e12602a 100644 --- a/src/main/java/com/yeogido/backend/domain/user/repository/UserRepository.java +++ b/src/main/java/com/yeogido/backend/domain/user/repository/UserRepository.java @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { -} \ No newline at end of file + + boolean existsByEmail(String email); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4cedb2e..6f30166 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,15 @@ cloud: app: s3: bucket: ${S3_BUCKET} + jwt: + secret: ${JWT_SECRET:local-jwt-secret-for-development} + access-token-expiration-minutes: 60 + refresh-token-expiration-days: 14 + oauth: + kakao: + user-info-uri: https://kapi.kakao.com/v2/user/me + social-signup-token-secret: ${SOCIAL_SIGNUP_TOKEN_SECRET:local-social-signup-secret} + social-signup-token-expiration-minutes: 10 server: forward-headers-strategy: framework @@ -38,4 +47,4 @@ server: springdoc: swagger-ui: operations-sorter: method - tags-sorter: alpha \ No newline at end of file + tags-sorter: alpha From dc25cf899fd713745982617a7805901d5ca31377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A4=80?= Date: Thu, 16 Jul 2026 10:58:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=9E=84=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20Redis=20=EC=A0=80=EC=9E=A5=20=EC=A0=81=EC=9A=A9=20#?= =?UTF-8?q?58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 24 +++++- .../service/SocialSignupTokenService.java | 79 +++++-------------- src/main/resources/application.yml | 6 +- 3 files changed, 48 insertions(+), 61 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 89ad1ce..4c2cfe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - - --default-time-zone=Asia/Seoul + - --defau아lt-time-zone=Asia/Seoul volumes: - mysql-data:/var/lib/mysql @@ -30,5 +30,25 @@ services: timeout: 5s retries: 10 + redis: + image: redis:7.4-alpine + container_name: yeogido-redis + restart: unless-stopped + + ports: + - "6379:6379" + + command: ["redis-server", "--appendonly", "yes"] + + volumes: + - redis-data:/data + + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + volumes: - mysql-data: \ No newline at end of file + mysql-data: + redis-data: diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java b/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java index 0c1deef..4e690c7 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java +++ b/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java @@ -7,94 +7,57 @@ import com.yeogido.backend.domain.auth.exception.AuthErrorCode; import com.yeogido.backend.global.exception.GeneralErrorCode; import com.yeogido.backend.global.exception.GeneralException; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Base64; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; +import java.time.Duration; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class SocialSignupTokenService { - private final ObjectMapper objectMapper; + private static final String KEY_PREFIX = "auth:social-signup:"; - @Value("${app.oauth.social-signup-token-secret}") - private String tokenSecret; + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; @Value("${app.oauth.social-signup-token-expiration-minutes}") private long expirationMinutes; public String issueToken(SocialUserInfo socialUserInfo) { - SocialSignupToken token = new SocialSignupToken( - SocialSignupTokenPayload.from(socialUserInfo), - Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES).toEpochMilli() - ); + String temporaryToken = UUID.randomUUID().toString(); + SocialSignupTokenPayload payload = SocialSignupTokenPayload.from(socialUserInfo); try { - String payload = encode(objectMapper.writeValueAsString(token)); - String signature = sign(payload); - return payload + "." + signature; + String payloadJson = objectMapper.writeValueAsString(payload); + stringRedisTemplate.opsForValue().set( + KEY_PREFIX + temporaryToken, + payloadJson, + Duration.ofMinutes(expirationMinutes) + ); + return temporaryToken; } catch (JsonProcessingException e) { throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); } } public SocialSignupTokenPayload getPayload(String temporaryToken) { - try { - String[] tokenParts = temporaryToken.split("\\."); - - if (tokenParts.length != 2 || !sign(tokenParts[0]).equals(tokenParts[1])) { - throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); - } + String payloadJson = stringRedisTemplate.opsForValue().get(KEY_PREFIX + temporaryToken); - SocialSignupToken token = objectMapper.readValue( - decode(tokenParts[0]), - SocialSignupToken.class - ); - - if (token.expiresAt() < Instant.now().toEpochMilli()) { - throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); - } - - return token.payload(); - } catch (JsonProcessingException | IllegalArgumentException e) { + if (payloadJson == null) { throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); } - } - public void deleteToken(String temporaryToken) { - } - - private String encode(String value) { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(value.getBytes(StandardCharsets.UTF_8)); - } - - private String decode(String value) { - return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); - } - - private String sign(String value) { try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(tokenSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); - } catch (Exception e) { + return objectMapper.readValue(payloadJson, SocialSignupTokenPayload.class); + } catch (JsonProcessingException e) { throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); } } - private record SocialSignupToken( - SocialSignupTokenPayload payload, - long expiresAt - ) { + public void deleteToken(String temporaryToken) { + stringRedisTemplate.delete(KEY_PREFIX + temporaryToken); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6f30166..71916af 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,11 @@ spring: password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + flyway: enabled: true locations: classpath:db/migration @@ -38,7 +43,6 @@ app: oauth: kakao: user-info-uri: https://kapi.kakao.com/v2/user/me - social-signup-token-secret: ${SOCIAL_SIGNUP_TOKEN_SECRET:local-social-signup-secret} social-signup-token-expiration-minutes: 10 server: From 299a8e1a81a6c0f8418fb6a582c4b872a4961c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A4=80?= Date: Thu, 16 Jul 2026 11:54:21 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/NaverUserInfoClient.java | 40 +++++++++++++++++++ .../auth/client/NaverUserInfoResponse.java | 31 ++++++++++++++ .../backend/domain/auth/dto/AuthReqDTO.java | 2 +- .../domain/auth/service/AuthService.java | 16 +++++--- src/main/resources/application.yml | 2 + 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoClient.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoResponse.java diff --git a/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoClient.java b/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoClient.java new file mode 100644 index 0000000..acf9170 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoClient.java @@ -0,0 +1,40 @@ +package com.yeogido.backend.domain.auth.client; + +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.exception.AuthErrorCode; +import com.yeogido.backend.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Component +@RequiredArgsConstructor +public class NaverUserInfoClient { + + private final RestClient.Builder restClientBuilder; + + @Value("${app.oauth.naver.user-info-uri}") + private String userInfoUri; + + public SocialUserInfo getUserInfo(String accessToken) { + try { + NaverUserInfoResponse response = restClientBuilder.build() + .get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(NaverUserInfoResponse.class); + + if (response == null || response.response() == null || response.response().id() == null) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_ACCESS_TOKEN); + } + + return response.toSocialUserInfo(); + } catch (RestClientException e) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_ACCESS_TOKEN); + } + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoResponse.java b/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoResponse.java new file mode 100644 index 0000000..9a4315d --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/client/NaverUserInfoResponse.java @@ -0,0 +1,31 @@ +package com.yeogido.backend.domain.auth.client; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.enums.SocialProvider; + +public record NaverUserInfoResponse( + String resultcode, + String message, + Response response +) { + + public SocialUserInfo toSocialUserInfo() { + return new SocialUserInfo( + SocialProvider.NAVER, + response.id(), + response.email(), + response.name(), + response.profileImageUrl() + ); + } + + public record Response( + String id, + String email, + String name, + @JsonProperty("profile_image") + String profileImageUrl + ) { + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java index f1c4d50..68b0ac2 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java +++ b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java @@ -52,7 +52,7 @@ public record Login( @Schema(name = "AuthSocialLoginReq", description = "소셜 로그인 요청") public record SocialLogin( - @Schema(description = "소셜 로그인 제공자", example = "KAKAO") + @Schema(description = "소셜 로그인 제공자", example = "KAKAO", allowableValues = {"KAKAO", "NAVER"}) @NotNull(message = "소셜 로그인 제공자는 필수 입력값입니다.") SocialProvider provider, diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java index 150e38f..4e65f88 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java @@ -1,6 +1,7 @@ package com.yeogido.backend.domain.auth.service; import com.yeogido.backend.domain.auth.client.KakaoUserInfoClient; +import com.yeogido.backend.domain.auth.client.NaverUserInfoClient; import com.yeogido.backend.domain.auth.dto.AuthReqDTO; import com.yeogido.backend.domain.auth.dto.AuthResDTO; import com.yeogido.backend.domain.auth.dto.SocialSignupTokenPayload; @@ -28,6 +29,7 @@ public class AuthService { private final KakaoUserInfoClient kakaoUserInfoClient; + private final NaverUserInfoClient naverUserInfoClient; private final SocialAccountRepository socialAccountRepository; private final SocialSignupTokenService socialSignupTokenService; private final JwtTokenProvider jwtTokenProvider; @@ -48,11 +50,7 @@ public AuthResDTO.Token login(AuthReqDTO.Login request) { @Transactional(readOnly = true) public AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request) { - if (request.provider() != SocialProvider.KAKAO) { - throw new GeneralException(AuthErrorCode.UNSUPPORTED_SOCIAL_PROVIDER); - } - - SocialUserInfo socialUserInfo = kakaoUserInfoClient.getUserInfo(request.accessToken()); + SocialUserInfo socialUserInfo = getSocialUserInfo(request); if (!StringUtils.hasText(socialUserInfo.email())) { throw new GeneralException(AuthErrorCode.SOCIAL_EMAIL_REQUIRED); @@ -66,6 +64,14 @@ public AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request) { .orElseGet(() -> createNewSocialLoginResponse(socialUserInfo)); } + private SocialUserInfo getSocialUserInfo(AuthReqDTO.SocialLogin request) { + return switch (request.provider()) { + case KAKAO -> kakaoUserInfoClient.getUserInfo(request.accessToken()); + case NAVER -> naverUserInfoClient.getUserInfo(request.accessToken()); + default -> throw new GeneralException(AuthErrorCode.UNSUPPORTED_SOCIAL_PROVIDER); + }; + } + private AuthResDTO.SocialLogin createExistingSocialLoginResponse(SocialAccount socialAccount) { User user = socialAccount.getUser(); AuthResDTO.Token token = jwtTokenProvider.issueToken(user); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 71916af..30426db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -43,6 +43,8 @@ app: oauth: kakao: user-info-uri: https://kapi.kakao.com/v2/user/me + naver: + user-info-uri: https://openapi.naver.com/v1/nid/me social-signup-token-expiration-minutes: 10 server: From 6b6c23cc948d0c6eb5bd27e35d1670c630febfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=A4=80?= Date: Sun, 19 Jul 2026 14:52:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20PR=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- .../domain/auth/converter/AuthConverter.java | 37 +++++ .../domain/auth/exception/AuthErrorCode.java | 6 + .../repository/SocialAccountRepository.java | 2 + .../domain/auth/service/AuthService.java | 146 ++---------------- .../domain/auth/service/AuthServiceImpl.java | 144 +++++++++++++++++ 6 files changed, 199 insertions(+), 138 deletions(-) create mode 100644 src/main/java/com/yeogido/backend/domain/auth/converter/AuthConverter.java create mode 100644 src/main/java/com/yeogido/backend/domain/auth/service/AuthServiceImpl.java diff --git a/docker-compose.yml b/docker-compose.yml index 4c2cfe5..fba46d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - - --defau아lt-time-zone=Asia/Seoul + - --default-time-zone=Asia/Seoul volumes: - mysql-data:/var/lib/mysql diff --git a/src/main/java/com/yeogido/backend/domain/auth/converter/AuthConverter.java b/src/main/java/com/yeogido/backend/domain/auth/converter/AuthConverter.java new file mode 100644 index 0000000..3cde790 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/converter/AuthConverter.java @@ -0,0 +1,37 @@ +package com.yeogido.backend.domain.auth.converter; + +import com.yeogido.backend.domain.auth.dto.AuthResDTO; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; + +public class AuthConverter { + + private AuthConverter() { + } + + public static AuthResDTO.SocialLogin toExistingSocialLoginResponse(AuthResDTO.Token token) { + return new AuthResDTO.SocialLogin( + false, + token.userId(), + token.accessToken(), + token.refreshToken(), + null, + null, + null + ); + } + + public static AuthResDTO.SocialLogin toNewSocialLoginResponse( + String temporaryToken, + SocialUserInfo socialUserInfo + ) { + return new AuthResDTO.SocialLogin( + true, + null, + null, + null, + temporaryToken, + socialUserInfo.email(), + socialUserInfo.name() + ); + } +} diff --git a/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java index 27aec7b..f9c2122 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java @@ -31,6 +31,12 @@ public enum AuthErrorCode implements ErrorCode { HttpStatus.BAD_REQUEST, "AUTH4003", "유효하지 않거나 만료된 소셜 회원가입 임시 토큰입니다." + ), + + SOCIAL_ACCOUNT_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "AUTH4091", + "이미 가입된 소셜 계정입니다." ); private final HttpStatus httpStatus; diff --git a/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java b/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java index 18bb249..05b90c5 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java +++ b/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java @@ -8,4 +8,6 @@ public interface SocialAccountRepository extends JpaRepository { Optional findByProviderAndProviderId(SocialProvider provider, String providerId); + + boolean existsByProviderAndProviderId(SocialProvider provider, String providerId); } diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java index 4e65f88..decbc43 100644 --- a/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/yeogido/backend/domain/auth/service/AuthService.java @@ -1,151 +1,23 @@ package com.yeogido.backend.domain.auth.service; -import com.yeogido.backend.domain.auth.client.KakaoUserInfoClient; -import com.yeogido.backend.domain.auth.client.NaverUserInfoClient; import com.yeogido.backend.domain.auth.dto.AuthReqDTO; import com.yeogido.backend.domain.auth.dto.AuthResDTO; -import com.yeogido.backend.domain.auth.dto.SocialSignupTokenPayload; -import com.yeogido.backend.domain.auth.dto.SocialUserInfo; -import com.yeogido.backend.domain.auth.entity.SocialAccount; -import com.yeogido.backend.domain.auth.enums.SocialProvider; -import com.yeogido.backend.domain.auth.exception.AuthErrorCode; -import com.yeogido.backend.domain.auth.repository.SocialAccountRepository; -import com.yeogido.backend.domain.region.entity.Region; -import com.yeogido.backend.domain.region.exception.RegionErrorCode; -import com.yeogido.backend.domain.region.repository.RegionRepository; -import com.yeogido.backend.domain.user.entity.User; -import com.yeogido.backend.domain.user.enums.UserRole; -import com.yeogido.backend.domain.user.enums.UserStatus; -import com.yeogido.backend.domain.user.exception.UserErrorCode; -import com.yeogido.backend.domain.user.repository.UserRepository; -import com.yeogido.backend.global.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -@Service -@RequiredArgsConstructor -public class AuthService { +public interface AuthService { - private final KakaoUserInfoClient kakaoUserInfoClient; - private final NaverUserInfoClient naverUserInfoClient; - private final SocialAccountRepository socialAccountRepository; - private final SocialSignupTokenService socialSignupTokenService; - private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; - private final RegionRepository regionRepository; + AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request); - public AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request) { - return new AuthResDTO.SignUp(1L); - } + AuthResDTO.Token login(AuthReqDTO.Login request); - public AuthResDTO.Token login(AuthReqDTO.Login request) { - return new AuthResDTO.Token( - 1L, - "mock_access_token_for_" + request.email(), - "mock_refresh_token_for_" + request.email() - ); - } + AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request); - @Transactional(readOnly = true) - public AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request) { - SocialUserInfo socialUserInfo = getSocialUserInfo(request); + AuthResDTO.Token completeSocialSignup(AuthReqDTO.SocialSignupComplete request); - if (!StringUtils.hasText(socialUserInfo.email())) { - throw new GeneralException(AuthErrorCode.SOCIAL_EMAIL_REQUIRED); - } + AuthResDTO.EmailCheck checkEmail(String email); - return socialAccountRepository.findByProviderAndProviderId( - socialUserInfo.provider(), - socialUserInfo.providerId() - ) - .map(this::createExistingSocialLoginResponse) - .orElseGet(() -> createNewSocialLoginResponse(socialUserInfo)); - } + void logout(String authorization); - private SocialUserInfo getSocialUserInfo(AuthReqDTO.SocialLogin request) { - return switch (request.provider()) { - case KAKAO -> kakaoUserInfoClient.getUserInfo(request.accessToken()); - case NAVER -> naverUserInfoClient.getUserInfo(request.accessToken()); - default -> throw new GeneralException(AuthErrorCode.UNSUPPORTED_SOCIAL_PROVIDER); - }; - } + void sendResetCode(AuthReqDTO.PasswordSendCode request); - private AuthResDTO.SocialLogin createExistingSocialLoginResponse(SocialAccount socialAccount) { - User user = socialAccount.getUser(); - AuthResDTO.Token token = jwtTokenProvider.issueToken(user); - - return new AuthResDTO.SocialLogin( - false, - token.userId(), - token.accessToken(), - token.refreshToken(), - null, - null, - null - ); - } - - private AuthResDTO.SocialLogin createNewSocialLoginResponse(SocialUserInfo socialUserInfo) { - String temporaryToken = socialSignupTokenService.issueToken(socialUserInfo); - - return new AuthResDTO.SocialLogin( - true, - null, - null, - null, - temporaryToken, - socialUserInfo.email(), - socialUserInfo.name() - ); - } - - @Transactional - public AuthResDTO.Token completeSocialSignup(AuthReqDTO.SocialSignupComplete request) { - SocialSignupTokenPayload payload = socialSignupTokenService.getPayload(request.temporaryToken()); - - if (userRepository.existsByEmail(payload.email())) { - throw new GeneralException(UserErrorCode.EMAIL_DUPLICATED); - } - - Region region = regionRepository.findById(request.regionId()) - .orElseThrow(() -> new GeneralException(RegionErrorCode.REGION_NOT_FOUND)); - - User user = userRepository.save(User.builder() - .nickname(request.name()) - .email(payload.email()) - .password(null) - .gender(request.gender()) - .birthYear(request.birthYear()) - .region(region) - .role(UserRole.USER) - .status(UserStatus.ACTIVE) - .profileImage(payload.profileImageUrl()) - .build()); - - socialAccountRepository.save(SocialAccount.builder() - .user(user) - .provider(payload.provider()) - .providerId(payload.providerId()) - .build()); - - socialSignupTokenService.deleteToken(request.temporaryToken()); - - return jwtTokenProvider.issueToken(user); - } - - public AuthResDTO.EmailCheck checkEmail(String email) { - return new AuthResDTO.EmailCheck(true); - } - - public void logout(String authorization) { - } - - public void sendResetCode(AuthReqDTO.PasswordSendCode request) { - } - - public AuthResDTO.PasswordVerifyCode verifyResetCode(AuthReqDTO.PasswordVerifyCode request) { - return new AuthResDTO.PasswordVerifyCode("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); - } + AuthResDTO.PasswordVerifyCode verifyResetCode(AuthReqDTO.PasswordVerifyCode request); } diff --git a/src/main/java/com/yeogido/backend/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/yeogido/backend/domain/auth/service/AuthServiceImpl.java new file mode 100644 index 0000000..9af6780 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,144 @@ +package com.yeogido.backend.domain.auth.service; + +import com.yeogido.backend.domain.auth.client.KakaoUserInfoClient; +import com.yeogido.backend.domain.auth.client.NaverUserInfoClient; +import com.yeogido.backend.domain.auth.converter.AuthConverter; +import com.yeogido.backend.domain.auth.dto.AuthReqDTO; +import com.yeogido.backend.domain.auth.dto.AuthResDTO; +import com.yeogido.backend.domain.auth.dto.SocialSignupTokenPayload; +import com.yeogido.backend.domain.auth.dto.SocialUserInfo; +import com.yeogido.backend.domain.auth.entity.SocialAccount; +import com.yeogido.backend.domain.auth.exception.AuthErrorCode; +import com.yeogido.backend.domain.auth.repository.SocialAccountRepository; +import com.yeogido.backend.domain.region.entity.Region; +import com.yeogido.backend.domain.region.exception.RegionErrorCode; +import com.yeogido.backend.domain.region.repository.RegionRepository; +import com.yeogido.backend.domain.user.entity.User; +import com.yeogido.backend.domain.user.enums.UserRole; +import com.yeogido.backend.domain.user.enums.UserStatus; +import com.yeogido.backend.domain.user.exception.UserErrorCode; +import com.yeogido.backend.domain.user.repository.UserRepository; +import com.yeogido.backend.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final KakaoUserInfoClient kakaoUserInfoClient; + private final NaverUserInfoClient naverUserInfoClient; + private final SocialAccountRepository socialAccountRepository; + private final SocialSignupTokenService socialSignupTokenService; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final RegionRepository regionRepository; + + @Override + public AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request) { + return new AuthResDTO.SignUp(1L); + } + + @Override + public AuthResDTO.Token login(AuthReqDTO.Login request) { + return new AuthResDTO.Token( + 1L, + "mock_access_token_for_" + request.email(), + "mock_refresh_token_for_" + request.email() + ); + } + + @Override + @Transactional(readOnly = true) + public AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request) { + SocialUserInfo socialUserInfo = getSocialUserInfo(request); + + if (!StringUtils.hasText(socialUserInfo.email())) { + throw new GeneralException(AuthErrorCode.SOCIAL_EMAIL_REQUIRED); + } + + return socialAccountRepository.findByProviderAndProviderId( + socialUserInfo.provider(), + socialUserInfo.providerId() + ) + .map(this::createExistingSocialLoginResponse) + .orElseGet(() -> createNewSocialLoginResponse(socialUserInfo)); + } + + private SocialUserInfo getSocialUserInfo(AuthReqDTO.SocialLogin request) { + return switch (request.provider()) { + case KAKAO -> kakaoUserInfoClient.getUserInfo(request.accessToken()); + case NAVER -> naverUserInfoClient.getUserInfo(request.accessToken()); + default -> throw new GeneralException(AuthErrorCode.UNSUPPORTED_SOCIAL_PROVIDER); + }; + } + + private AuthResDTO.SocialLogin createExistingSocialLoginResponse(SocialAccount socialAccount) { + AuthResDTO.Token token = jwtTokenProvider.issueToken(socialAccount.getUser()); + return AuthConverter.toExistingSocialLoginResponse(token); + } + + private AuthResDTO.SocialLogin createNewSocialLoginResponse(SocialUserInfo socialUserInfo) { + String temporaryToken = socialSignupTokenService.issueToken(socialUserInfo); + return AuthConverter.toNewSocialLoginResponse(temporaryToken, socialUserInfo); + } + + @Override + @Transactional + public AuthResDTO.Token completeSocialSignup(AuthReqDTO.SocialSignupComplete request) { + SocialSignupTokenPayload payload = socialSignupTokenService.getPayload(request.temporaryToken()); + + if (socialAccountRepository.existsByProviderAndProviderId(payload.provider(), payload.providerId())) { + throw new GeneralException(AuthErrorCode.SOCIAL_ACCOUNT_ALREADY_EXISTS); + } + + if (userRepository.existsByEmail(payload.email())) { + throw new GeneralException(UserErrorCode.EMAIL_DUPLICATED); + } + + Region region = regionRepository.findById(request.regionId()) + .orElseThrow(() -> new GeneralException(RegionErrorCode.REGION_NOT_FOUND)); + + User user = userRepository.save(User.builder() + .nickname(request.name()) + .email(payload.email()) + .password(null) + .gender(request.gender()) + .birthYear(request.birthYear()) + .region(region) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .profileImage(payload.profileImageUrl()) + .build()); + + socialAccountRepository.save(SocialAccount.builder() + .user(user) + .provider(payload.provider()) + .providerId(payload.providerId()) + .build()); + + socialSignupTokenService.deleteToken(request.temporaryToken()); + + return jwtTokenProvider.issueToken(user); + } + + @Override + public AuthResDTO.EmailCheck checkEmail(String email) { + return new AuthResDTO.EmailCheck(true); + } + + @Override + public void logout(String authorization) { + } + + @Override + public void sendResetCode(AuthReqDTO.PasswordSendCode request) { + } + + @Override + public AuthResDTO.PasswordVerifyCode verifyResetCode(AuthReqDTO.PasswordVerifyCode request) { + return new AuthResDTO.PasswordVerifyCode("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + } +}