diff --git a/docker-compose.yml b/docker-compose.yml index 89ad1ce..fba46d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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/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/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/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/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/dto/AuthReqDTO.java b/src/main/java/com/yeogido/backend/domain/auth/dto/AuthReqDTO.java index 9d0cf9b..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 @@ -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; @@ -51,18 +52,37 @@ 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, - @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..f9c2122 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,45 @@ +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", + "유효하지 않거나 만료된 소셜 회원가입 임시 토큰입니다." + ), + + SOCIAL_ACCOUNT_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "AUTH4091", + "이미 가입된 소셜 계정입니다." + ); + + 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..05b90c5 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/repository/SocialAccountRepository.java @@ -0,0 +1,13 @@ +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); + + 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 d65be6e..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 @@ -2,42 +2,22 @@ import com.yeogido.backend.domain.auth.dto.AuthReqDTO; import com.yeogido.backend.domain.auth.dto.AuthResDTO; -import org.springframework.stereotype.Service; - -@Service -public class AuthService { - - public AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request) { - return new AuthResDTO.SignUp(1L); - } - - public AuthResDTO.Token login(AuthReqDTO.Login request) { - return new AuthResDTO.Token( - 1L, - "mock_access_token_for_" + request.email(), - "mock_refresh_token_for_" + request.email() - ); - } - - public AuthResDTO.Token socialLogin(AuthReqDTO.SocialLogin request) { - return new AuthResDTO.Token( - 2L, - "mock_social_access_token", - "mock_social_refresh_token" - ); - } - - 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"); - } + +public interface AuthService { + + AuthResDTO.SignUp signUp(AuthReqDTO.SignUp request); + + AuthResDTO.Token login(AuthReqDTO.Login request); + + AuthResDTO.SocialLogin socialLogin(AuthReqDTO.SocialLogin request); + + AuthResDTO.Token completeSocialSignup(AuthReqDTO.SocialSignupComplete request); + + AuthResDTO.EmailCheck checkEmail(String email); + + void logout(String authorization); + + void sendResetCode(AuthReqDTO.PasswordSendCode request); + + 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"); + } +} 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..4e690c7 --- /dev/null +++ b/src/main/java/com/yeogido/backend/domain/auth/service/SocialSignupTokenService.java @@ -0,0 +1,63 @@ +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.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 static final String KEY_PREFIX = "auth:social-signup:"; + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + + @Value("${app.oauth.social-signup-token-expiration-minutes}") + private long expirationMinutes; + + public String issueToken(SocialUserInfo socialUserInfo) { + String temporaryToken = UUID.randomUUID().toString(); + SocialSignupTokenPayload payload = SocialSignupTokenPayload.from(socialUserInfo); + + try { + 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) { + String payloadJson = stringRedisTemplate.opsForValue().get(KEY_PREFIX + temporaryToken); + + if (payloadJson == null) { + throw new GeneralException(AuthErrorCode.INVALID_SOCIAL_SIGNUP_TOKEN); + } + + try { + return objectMapper.readValue(payloadJson, SocialSignupTokenPayload.class); + } catch (JsonProcessingException e) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR); + } + } + + public void deleteToken(String temporaryToken) { + stringRedisTemplate.delete(KEY_PREFIX + temporaryToken); + } +} 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..30426db 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 @@ -31,6 +36,16 @@ 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 + naver: + user-info-uri: https://openapi.naver.com/v1/nid/me + social-signup-token-expiration-minutes: 10 server: forward-headers-strategy: framework @@ -38,4 +53,4 @@ server: springdoc: swagger-ui: operations-sorter: method - tags-sorter: alpha \ No newline at end of file + tags-sorter: alpha