diff --git a/api/src/main/kotlin/handler/UserHandler.kt b/api/src/main/kotlin/handler/UserHandler.kt index 48011722..ea890b78 100644 --- a/api/src/main/kotlin/handler/UserHandler.kt +++ b/api/src/main/kotlin/handler/UserHandler.kt @@ -1,5 +1,6 @@ package com.wafflestudio.snu4t.handler +import com.wafflestudio.snu4t.auth.SocialProvider import com.wafflestudio.snu4t.common.dto.OkResponse import com.wafflestudio.snu4t.common.extension.toZonedDateTime import com.wafflestudio.snu4t.middleware.SnuttRestApiDefaultMiddleware @@ -8,6 +9,8 @@ import com.wafflestudio.snu4t.users.dto.EmailVerificationResultDto import com.wafflestudio.snu4t.users.dto.LocalLoginRequest import com.wafflestudio.snu4t.users.dto.PasswordChangeRequest import com.wafflestudio.snu4t.users.dto.SendEmailRequest +import com.wafflestudio.snu4t.users.dto.SocialLoginRequest +import com.wafflestudio.snu4t.users.dto.SocialProvidersCheckDto import com.wafflestudio.snu4t.users.dto.UserDto import com.wafflestudio.snu4t.users.dto.UserLegacyDto import com.wafflestudio.snu4t.users.dto.UserPatchRequest @@ -99,6 +102,58 @@ class UserHandler( userService.attachLocal(user, body) } + suspend fun attachFacebook(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + val socialLoginRequest: SocialLoginRequest = req.awaitBody() + userService.attachSocial(user, socialLoginRequest, SocialProvider.FACEBOOK) + } + + suspend fun attachGoogle(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + val socialLoginRequest: SocialLoginRequest = req.awaitBody() + userService.attachSocial(user, socialLoginRequest, SocialProvider.GOOGLE) + } + + suspend fun attachKakao(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + val socialLoginRequest: SocialLoginRequest = req.awaitBody() + userService.attachSocial(user, socialLoginRequest, SocialProvider.KAKAO) + } + + suspend fun detachFacebook(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + userService.detachSocial(user, SocialProvider.FACEBOOK) + } + + suspend fun detachGoogle(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + userService.detachSocial(user, SocialProvider.GOOGLE) + } + + suspend fun detachKakao(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + userService.detachSocial(user, SocialProvider.KAKAO) + } + + suspend fun checkSocialProviders(req: ServerRequest): ServerResponse = + handle(req) { + val user = req.getContext().user!! + + SocialProvidersCheckDto( + local = user.credential.localId != null, + facebook = user.credential.fbName != null, + google = user.credential.googleSub != null, + kakao = user.credential.kakaoSub != null, + apple = user.credential.appleSub != null, + ) + } + suspend fun changePassword(req: ServerRequest): ServerResponse = handle(req) { val user = req.getContext().user!! diff --git a/api/src/main/kotlin/router/MainRouter.kt b/api/src/main/kotlin/router/MainRouter.kt index 4e29ca92..79ab91da 100644 --- a/api/src/main/kotlin/router/MainRouter.kt +++ b/api/src/main/kotlin/router/MainRouter.kt @@ -107,10 +107,17 @@ class MainRouter( POST("/email/verification/code", userHandler::confirmEmailVerification) POST("/password", userHandler::attachLocal) PUT("/password", userHandler::changePassword) + POST("/facebook", userHandler::attachFacebook) + POST("/google", userHandler::attachGoogle) + POST("/kakao", userHandler::attachKakao) + DELETE("/facebook", userHandler::detachFacebook) + DELETE("/google", userHandler::detachGoogle) + DELETE("/kakao", userHandler::detachKakao) } "/users".nest { GET("/me", userHandler::getUserMe) PATCH("/me", userHandler::patchUserInfo) + GET("/me/social_providers", userHandler::checkSocialProviders) } } diff --git a/api/src/main/kotlin/router/docs/UserDocs.kt b/api/src/main/kotlin/router/docs/UserDocs.kt index 267b0ecc..782e8ce1 100644 --- a/api/src/main/kotlin/router/docs/UserDocs.kt +++ b/api/src/main/kotlin/router/docs/UserDocs.kt @@ -5,6 +5,8 @@ import com.wafflestudio.snu4t.users.dto.EmailVerificationResultDto import com.wafflestudio.snu4t.users.dto.LocalLoginRequest import com.wafflestudio.snu4t.users.dto.PasswordChangeRequest import com.wafflestudio.snu4t.users.dto.SendEmailRequest +import com.wafflestudio.snu4t.users.dto.SocialLoginRequest +import com.wafflestudio.snu4t.users.dto.SocialProvidersCheckDto import com.wafflestudio.snu4t.users.dto.TokenResponse import com.wafflestudio.snu4t.users.dto.UserDto import com.wafflestudio.snu4t.users.dto.UserLegacyDto @@ -78,6 +80,21 @@ import org.springframework.web.bind.annotation.RequestMethod responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = UserDto::class))])], ), ), + RouterOperation( + path = "/v1/users/me/social_providers", + method = [RequestMethod.GET], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "getSocialProviders", + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = SocialProvidersCheckDto::class))], + ), + ], + ), + ), RouterOperation( path = "/v1/user/info", method = [RequestMethod.GET], @@ -230,5 +247,122 @@ import org.springframework.web.bind.annotation.RequestMethod ], ), ), + RouterOperation( + path = "/v1/user/facebook", + method = [RequestMethod.POST], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "attachFacebook", + requestBody = + RequestBody( + content = [ + Content( + schema = Schema(implementation = SocialLoginRequest::class), + mediaType = MediaType.APPLICATION_JSON_VALUE, + ), + ], + ), + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), + RouterOperation( + path = "/v1/user/google", + method = [RequestMethod.POST], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "attachGoogle", + requestBody = + RequestBody( + content = [ + Content( + schema = Schema(implementation = SocialLoginRequest::class), + mediaType = MediaType.APPLICATION_JSON_VALUE, + ), + ], + ), + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), + RouterOperation( + path = "/v1/user/kakao", + method = [RequestMethod.POST], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "attachKakao", + requestBody = + RequestBody( + content = [ + Content( + schema = Schema(implementation = SocialLoginRequest::class), + mediaType = MediaType.APPLICATION_JSON_VALUE, + ), + ], + ), + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), + RouterOperation( + path = "/v1/user/facebook", + method = [RequestMethod.DELETE], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "detachFacebook", + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), + RouterOperation( + path = "/v1/user/google", + method = [RequestMethod.DELETE], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "detachGoogle", + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), + RouterOperation( + path = "/v1/user/kakao", + method = [RequestMethod.DELETE], + produces = [MediaType.APPLICATION_JSON_VALUE], + operation = + Operation( + operationId = "detachKakao", + responses = [ + ApiResponse( + responseCode = "200", + content = [Content(schema = Schema(implementation = TokenResponse::class))], + ), + ], + ), + ), ) annotation class UserDocs diff --git a/core/src/main/kotlin/common/exception/ErrorType.kt b/core/src/main/kotlin/common/exception/ErrorType.kt index 19e98bc1..037f2b24 100644 --- a/core/src/main/kotlin/common/exception/ErrorType.kt +++ b/core/src/main/kotlin/common/exception/ErrorType.kt @@ -51,6 +51,7 @@ enum class ErrorType( TOO_MANY_VERIFICATION_CODE_REQUEST(HttpStatus.BAD_REQUEST, 40017, "인증 코드 요청이 너무 많습니다.", "인증 코드 요청이 너무 많습니다."), INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, 40018, "인증 코드가 유효하지 않습니다.", "인증 코드가 유효하지 않습니다."), ALREADY_LOCAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40019, "이미 로컬 계정이 존재합니다.", "이미 로컬 계정이 존재합니다."), + ALREADY_SOCIAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40020, "이미 소셜 계정이 존재합니다.", "이미 소셜 계정이 존재합니다."), SOCIAL_CONNECT_FAIL(HttpStatus.UNAUTHORIZED, 40100, "소셜 로그인에 실패했습니다.", "소셜 로그인에 실패했습니다."), @@ -72,6 +73,7 @@ enum class ErrorType( INVALID_THEME_TYPE(HttpStatus.CONFLICT, 40905, "적절하지 않은 유형의 테마입니다.", "적절하지 않은 유형의 테마입니다."), DUPLICATE_POPUP_KEY(HttpStatus.CONFLICT, 40906, "중복된 팝업 키입니다.", "중복된 팝업 키입니다."), ALREADY_DOWNLOADED_THEME(HttpStatus.CONFLICT, 40907, "이미 다운로드한 테마입니다.", "이미 다운로드한 테마입니다."), + DUPLICATE_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, 40908, "이미 연결된 소셜 계정입니다.", "이미 연결된 소셜 계정입니다."), DYNAMIC_LINK_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "링크 생성 실패", "링크 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), } diff --git a/core/src/main/kotlin/common/exception/Snu4tException.kt b/core/src/main/kotlin/common/exception/Snu4tException.kt index 7c89a4cc..3d04c6c9 100644 --- a/core/src/main/kotlin/common/exception/Snu4tException.kt +++ b/core/src/main/kotlin/common/exception/Snu4tException.kt @@ -77,6 +77,8 @@ object InvalidVerificationCodeException : Snu4tException(ErrorType.INVALID_VERIF object AlreadyLocalAccountException : Snu4tException(ErrorType.ALREADY_LOCAL_ACCOUNT) +object AlreadySocialAccountException : Snu4tException(ErrorType.ALREADY_SOCIAL_ACCOUNT) + object SocialConnectFailException : Snu4tException(ErrorType.SOCIAL_CONNECT_FAIL) object NoUserFcmKeyException : Snu4tException(ErrorType.NO_USER_FCM_KEY) @@ -135,4 +137,6 @@ object DuplicatePopupKeyException : Snu4tException(ErrorType.DUPLICATE_POPUP_KEY object AlreadyDownloadedThemeException : Snu4tException(ErrorType.ALREADY_DOWNLOADED_THEME) +object DuplicateSocialAccountException : Snu4tException(ErrorType.DUPLICATE_SOCIAL_ACCOUNT) + object DynamicLinkGenerationFailedException : Snu4tException(ErrorType.DYNAMIC_LINK_GENERATION_FAILED) diff --git a/core/src/main/kotlin/users/dto/SocialProvidersCheckDto.kt b/core/src/main/kotlin/users/dto/SocialProvidersCheckDto.kt new file mode 100644 index 00000000..a8d56652 --- /dev/null +++ b/core/src/main/kotlin/users/dto/SocialProvidersCheckDto.kt @@ -0,0 +1,9 @@ +package com.wafflestudio.snu4t.users.dto + +data class SocialProvidersCheckDto( + val local: Boolean, + val facebook: Boolean, + val google: Boolean, + val kakao: Boolean, + val apple: Boolean, +) diff --git a/core/src/main/kotlin/users/repository/UserRepository.kt b/core/src/main/kotlin/users/repository/UserRepository.kt index 7816b247..6310fe9f 100644 --- a/core/src/main/kotlin/users/repository/UserRepository.kt +++ b/core/src/main/kotlin/users/repository/UserRepository.kt @@ -28,11 +28,15 @@ interface UserRepository : CoroutineCrudRepository { suspend fun findAllByIdInAndActiveTrue(ids: List): List - suspend fun findByEmailAndActiveTrue(email: String): User? - suspend fun existsByEmailAndIsEmailVerifiedTrueAndActiveTrue(email: String): Boolean suspend fun findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email: String): User? + suspend fun existsByCredentialFbIdAndActiveTrue(fbId: String): Boolean + + suspend fun existsByCredentialGoogleSubAndActiveTrue(googleSub: String): Boolean + + suspend fun existsByCredentialKakaoSubAndActiveTrue(kakaoSub: String): Boolean + fun findAllByNicknameStartingWith(nickname: String): Flow } diff --git a/core/src/main/kotlin/users/service/UserService.kt b/core/src/main/kotlin/users/service/UserService.kt index c28a4dd9..39be57ab 100644 --- a/core/src/main/kotlin/users/service/UserService.kt +++ b/core/src/main/kotlin/users/service/UserService.kt @@ -6,8 +6,10 @@ import com.wafflestudio.snu4t.auth.SocialProvider import com.wafflestudio.snu4t.common.cache.Cache import com.wafflestudio.snu4t.common.cache.CacheKey import com.wafflestudio.snu4t.common.exception.AlreadyLocalAccountException +import com.wafflestudio.snu4t.common.exception.AlreadySocialAccountException import com.wafflestudio.snu4t.common.exception.DuplicateEmailException import com.wafflestudio.snu4t.common.exception.DuplicateLocalIdException +import com.wafflestudio.snu4t.common.exception.DuplicateSocialAccountException import com.wafflestudio.snu4t.common.exception.EmailAlreadyVerifiedException import com.wafflestudio.snu4t.common.exception.InvalidEmailException import com.wafflestudio.snu4t.common.exception.InvalidLocalIdException @@ -90,6 +92,17 @@ interface UserService { localLoginRequest: LocalLoginRequest, ): TokenResponse + suspend fun attachSocial( + user: User, + socialLoginRequest: SocialLoginRequest, + socialProvider: SocialProvider, + ): TokenResponse + + suspend fun detachSocial( + user: User, + socialProvider: SocialProvider, + ): TokenResponse + suspend fun changePassword( user: User, passwordChangeRequest: PasswordChangeRequest, @@ -271,13 +284,16 @@ class UserServiceImpl( return signup(credential, oauth2UserResponse.email, false) } - private fun getSocialProvider(user: User): SocialProvider { + private fun getSocialProvider( + user: User, + filter: SocialProvider? = null, + ): SocialProvider { return when { - user.credential.fbId != null -> SocialProvider.FACEBOOK - user.credential.appleSub != null -> SocialProvider.APPLE - user.credential.googleSub != null -> SocialProvider.GOOGLE - user.credential.kakaoSub != null -> SocialProvider.KAKAO - user.credential.localId != null -> SocialProvider.LOCAL + user.credential.fbId != null && filter != SocialProvider.FACEBOOK -> SocialProvider.FACEBOOK + user.credential.appleSub != null && filter != SocialProvider.APPLE -> SocialProvider.APPLE + user.credential.googleSub != null && filter != SocialProvider.GOOGLE -> SocialProvider.GOOGLE + user.credential.kakaoSub != null && filter != SocialProvider.KAKAO -> SocialProvider.KAKAO + user.credential.localId != null && filter != SocialProvider.LOCAL -> SocialProvider.LOCAL else -> throw IllegalStateException("Unknown social provider") } } @@ -373,6 +389,91 @@ class UserServiceImpl( return TokenResponse(token = user.credentialHash) } + override suspend fun attachSocial( + user: User, + socialLoginRequest: SocialLoginRequest, + socialProvider: SocialProvider, + ): TokenResponse { + val token = socialLoginRequest.token + val oauth2UserResponse = authService.socialLoginWithAccessToken(socialProvider, token) + val presentUser = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(oauth2UserResponse.email!!) + + if (presentUser != null && presentUser.id != user.id) { + throw DuplicateEmailException(socialProvider) + } + when (socialProvider) { + SocialProvider.FACEBOOK -> { + if (user.credential.fbId != null) throw AlreadySocialAccountException + if (userRepository.existsByCredentialFbIdAndActiveTrue(oauth2UserResponse.socialId)) { + throw DuplicateSocialAccountException + } + val facebookCredential = authService.buildFacebookCredential(oauth2UserResponse) + user.credential.apply { + fbId = facebookCredential.fbId + fbName = facebookCredential.fbName + } + } + SocialProvider.GOOGLE -> { + if (user.credential.googleSub != null) throw AlreadySocialAccountException + if (userRepository.existsByCredentialGoogleSubAndActiveTrue(oauth2UserResponse.socialId)) { + throw DuplicateSocialAccountException + } + val googleCredential = authService.buildGoogleCredential(oauth2UserResponse) + user.credential.apply { + googleSub = googleCredential.googleSub + googleEmail = googleCredential.googleEmail + } + } + SocialProvider.KAKAO -> { + if (user.credential.kakaoSub != null) throw AlreadySocialAccountException + if (userRepository.existsByCredentialKakaoSubAndActiveTrue(oauth2UserResponse.socialId)) { + throw DuplicateSocialAccountException + } + val kakaoCredential = authService.buildKakaoCredential(oauth2UserResponse) + user.credential.apply { + kakaoSub = kakaoCredential.kakaoSub + kakaoEmail = kakaoCredential.kakaoEmail + } + } + else -> throw IllegalStateException("Unknown social provider") + } + + user.credentialHash = authService.generateCredentialHash(user.credential) + userRepository.save(user) + return TokenResponse(token = user.credentialHash) + } + + override suspend fun detachSocial( + user: User, + socialProvider: SocialProvider, + ): TokenResponse { + getSocialProvider(user, filter = socialProvider) + when (socialProvider) { + SocialProvider.FACEBOOK -> { + user.credential.apply { + fbId = null + fbName = null + } + } + SocialProvider.GOOGLE -> { + user.credential.apply { + googleSub = null + googleEmail = null + } + } + SocialProvider.KAKAO -> { + user.credential.apply { + kakaoSub = null + kakaoEmail = null + } + } + else -> throw IllegalStateException("Unknown social provider") + } + user.credentialHash = authService.generateCredentialHash(user.credential) + userRepository.save(user) + return TokenResponse(token = user.credentialHash) + } + override suspend fun changePassword( user: User, passwordChangeRequest: PasswordChangeRequest, @@ -388,14 +489,12 @@ class UserServiceImpl( } override suspend fun sendLocalIdToEmail(email: String) { - if (!authService.isValidEmail(email)) throw InvalidEmailException - val user = userRepository.findByEmailAndActiveTrue(email) ?: throw UserNotFoundException + val user = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email) ?: throw UserNotFoundException mailService.sendUserMail(type = UserMailType.FIND_ID, to = email, localId = user.credential.localId ?: throw UserNotFoundException) } override suspend fun sendResetPasswordCode(email: String) { - if (!authService.isValidEmail(email)) throw InvalidEmailException - val user = userRepository.findByEmailAndActiveTrue(email) ?: throw UserNotFoundException + val user = userRepository.findByEmailAndIsEmailVerifiedTrueAndActiveTrue(email) ?: throw UserNotFoundException val key = RESET_PASSWORD_CODE_PREFIX + user.id val code = Base64.getUrlEncoder().encodeToString(Random.nextBytes(6)) saveNewVerificationValue(email, code, key)