Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

카카오 로그인 기능 구현 #54

Merged
merged 4 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ jobs:
-e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
-e MAIL_USERNAME=${{ secrets.MAIL_USERNAME }} \
-e MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }} \
-e KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} \
--name memowithtags-backend \
\$REPOSITORY_URI:\$TAG" > deploy.sh

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.wafflestudio.toyproject.memoWithTags.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.converter.FormHttpMessageConverter
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.web.client.RestTemplate

@Configuration
class RestTemplateConfig {
@Bean
fun restTemplate(): RestTemplate {
val restTemplate = RestTemplate()
val messageConverters: MutableList<HttpMessageConverter<*>> = mutableListOf()
messageConverters.add(FormHttpMessageConverter())
restTemplate.messageConverters = messageConverters

return restTemplate
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
package com.wafflestudio.toyproject.memoWithTags.exception
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode

class InValidTokenException : CustomException(
sealed class TokenExceptions(
httpErrorCode: HttpStatusCode,
errorCode: Int,
msg: String
) : CustomException(httpErrorCode, errorCode, msg)

class TokenExpiredException : TokenExceptions(
errorCode = 0,
httpErrorCode = HttpStatus.UNAUTHORIZED,
msg = "토큰이 만료되었습니다."
)

class InvalidTokenSignatureException : TokenExceptions(
errorCode = 0,
httpErrorCode = HttpStatus.UNAUTHORIZED,
msg = "토큰의 서명이 유효하지 않습니다."
)

class InValidTokenException : TokenExceptions(
errorCode = 0,
httpErrorCode = HttpStatus.UNAUTHORIZED,
msg = "토큰이 유효하지 않습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ class AuthenticationFailedException : UserException(
httpErrorCode = HttpStatus.UNAUTHORIZED,
msg = "인증에 실패했습니다."
)

// 소셜 로그인 요청 실패
class OAuthRequestException : UserException(
errorCode = 0,
httpErrorCode = HttpStatus.BAD_REQUEST,
msg = "소셜 로그인 요청에 실패했습니다."
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.wafflestudio.toyproject.memoWithTags.user

import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.wafflestudio.toyproject.memoWithTags.exception.OAuthRequestException
import com.wafflestudio.toyproject.memoWithTags.user.dto.KakaoOAuthToken
import com.wafflestudio.toyproject.memoWithTags.user.dto.KakaoProfile
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.util.MultiValueMap
import org.springframework.web.client.RestTemplate

@Component
class KakaoUtil(
@Value("\${kakao.auth.client}")
private val kakaoClient: String,
@Value("\${kakao.auth.redirect}")
private val kakaoRedirect: String
) {
private val logger = LoggerFactory.getLogger(javaClass)

fun requestToken(accessCode: String): KakaoOAuthToken {
val restTemplate = RestTemplate()
val headers = HttpHeaders()
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8")

val params: MultiValueMap<String, String> = LinkedMultiValueMap<String, String>().apply {
add("grant_type", "authorization_code")
add("client_id", kakaoClient)
add("redirect_url", kakaoRedirect)
add("code", accessCode)
}

val kakaoTokenRequest = HttpEntity(params, headers)
logger.info("Token Request: $kakaoTokenRequest")

val response: ResponseEntity<String> = restTemplate.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String::class.java
)
logger.info("Token Response: $response")

val objectMapper = ObjectMapper().registerKotlinModule()

return try {
val oAuthToken = objectMapper.readValue(response.body, KakaoOAuthToken::class.java)
logger.info("oAuthToken: ${oAuthToken.access_token}")
oAuthToken
} catch (e: JsonProcessingException) {
logger.info("Token processing error: ${e.message}")
throw OAuthRequestException()
}
}

fun requestProfile(oAuthToken: KakaoOAuthToken): KakaoProfile {
val restTemplate = RestTemplate()
val headers = HttpHeaders().apply {
add("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
add("Authorization", "Bearer ${oAuthToken.access_token}")
}

val kakaoProfileRequest: HttpEntity<MultiValueMap<String, String>> = HttpEntity(headers)
logger.info("Profile Request: $kakaoProfileRequest")

val response: ResponseEntity<String> = restTemplate.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
kakaoProfileRequest,
String::class.java
)
logger.info("Profile Response: $response")

val objectMapper = ObjectMapper().registerKotlinModule()
return try {
val kakaoProfile = objectMapper.readValue(response.body, KakaoProfile::class.java)
logger.info("kakao email: ${kakaoProfile.kakao_account.email}")
kakaoProfile
} catch (e: JsonProcessingException) {
logger.info("Profile processing error: ${e.message}")
throw OAuthRequestException()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.wafflestudio.toyproject.memoWithTags.user.controller

import com.wafflestudio.toyproject.memoWithTags.user.dto.UserResponse.LoginResponse
import com.wafflestudio.toyproject.memoWithTags.user.service.SocialLoginService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1")
class SocialLoginController(
private val socialLoginService: SocialLoginService
) {
@GetMapping("/oauth/naver")
fun naverCallback() {
}

@GetMapping("/oauth/kakao")
fun kakaoCallback(
@RequestParam("code") code: String
): ResponseEntity<LoginResponse> {
val (_, accessToken, refreshToken) = socialLoginService.kakaoCallBack(code)
return ResponseEntity.ok(LoginResponse(accessToken, refreshToken))
}

@GetMapping("/oauth/google")
fun googleCallback() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

Expand Down Expand Up @@ -50,16 +48,6 @@ class UserController(
return ResponseEntity.ok(LoginResponse(accessToken, refreshToken))
}

@Operation(summary = "소셜 로그인")
@PostMapping("/auth/login/{socialType}")
fun loginSocial(
@RequestHeader(name = "Authorization") token: String,
@PathVariable("socialType") socialType: String
): ResponseEntity<LoginResponse> {
// val (_, accessToken, refreshToken) = userService.loginSocial(token, socialType)
return ResponseEntity.ok(LoginResponse("", ""))
}

@Operation(summary = "비밀번호 찾기 이메일 인증")
@PostMapping("/auth/forgot-password")
fun forgotPassword(@RequestBody request: ForgotPasswordRequest): ResponseEntity<Unit> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.wafflestudio.toyproject.memoWithTags.user.dto

data class KakaoOAuthToken(
val token_type: String,
val access_token: String,
val expires_in: Int,
val refresh_token: String,
val refresh_token_expires_in: Int,
val scope: String?
)

data class KakaoProfile(
val id: Long,
val connected_at: String,
val properties: Properties,
val kakao_account: KakaoAccount
)
data class Properties(
val nickname: String
)
data class KakaoAccount(
val profile_nickname_needs_agreement: Boolean,
val profile: Profile,
val has_email: Boolean,
val email_needs_agreement: Boolean,
val is_email_valid: Boolean,
val is_email_verified: Boolean,
val email: String
) {
data class Profile(
val nickname: String,
val is_default_nickname: Boolean
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.wafflestudio.toyproject.memoWithTags.user.service

import com.wafflestudio.toyproject.memoWithTags.user.JwtUtil
import com.wafflestudio.toyproject.memoWithTags.user.KakaoUtil
import com.wafflestudio.toyproject.memoWithTags.user.SocialType
import com.wafflestudio.toyproject.memoWithTags.user.controller.User
import com.wafflestudio.toyproject.memoWithTags.user.dto.KakaoOAuthToken
import com.wafflestudio.toyproject.memoWithTags.user.dto.KakaoProfile
import com.wafflestudio.toyproject.memoWithTags.user.persistence.UserEntity
import com.wafflestudio.toyproject.memoWithTags.user.persistence.UserRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant

@Service
class SocialLoginService(
private val userRepository: UserRepository,
private val kakaoUtil: KakaoUtil
) {
private val logger = LoggerFactory.getLogger(javaClass)

fun kakaoCallBack(accessCode: String): Triple<User, String, String> {
val oAuthToken: KakaoOAuthToken = kakaoUtil.requestToken(accessCode)
val kakaoProfile: KakaoProfile = kakaoUtil.requestProfile(oAuthToken)

val kakaoEmail = kakaoProfile.kakao_account.email
val userEntity = userRepository.findByEmail(kakaoEmail)
val user: User = if (userEntity != null && userEntity.socialType == SocialType.KAKAO) {
logger.info("user already exists: ${userEntity.id}, ${userEntity.email}")
User.fromEntity(userEntity)
} else {
logger.info("creating user $kakaoEmail")
createKakaoUser(kakaoProfile)
}

return Triple(
user,
JwtUtil.generateAccessToken(kakaoEmail),
JwtUtil.generateRefreshToken(kakaoEmail)
)
}

fun createKakaoUser(kakaoProfile: KakaoProfile): User {
val kakaoEmail = kakaoProfile.kakao_account.email
val kakaoNickname = kakaoProfile.kakao_account.profile.nickname
val encryptedPassword = "kakao_registered_user"

val userEntity = userRepository.save(
UserEntity(
email = kakaoEmail,
nickname = kakaoNickname,
hashedPassword = encryptedPassword,
verified = true,
socialType = SocialType.KAKAO,
createdAt = Instant.now()
)
)

return User.fromEntity(userEntity)
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ spring:
trust: smtp.gmail.com
debug: true

kakao:
auth:
client: ${KAKAO_CLIENT_ID}
redirect: http://localhost:8080/api/v1/oauth/kakao

springdoc:
override-with-generic-response: false
# 이후 jwt도 yml에 넣어서 관리 예정
Loading