Skip to content

Commit

Permalink
Merge pull request #5 from soma-baekgu/BG-133-link-oauth
Browse files Browse the repository at this point in the history
[BG-133]: 구글 OAuth2 연동하기 (6.5h / 5h)
  • Loading branch information
Dltmd202 authored Jun 26, 2024
2 parents 4f8a2ca + 8e33210 commit ada0d24
Show file tree
Hide file tree
Showing 20 changed files with 417 additions and 4 deletions.
2 changes: 0 additions & 2 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ repositories {
dependencies {
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")

testImplementation(kotlin("test"))
}
Expand Down
40 changes: 40 additions & 0 deletions api/src/main/kotlin/com/backgu/amaker/auth/config/AuthConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.backgu.amaker.auth.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration

@Configuration
class AuthConfig {
@Value("\${oauth.google.client-id}")
lateinit var clientId: String

@Value("\${oauth.google.client-secret}")
lateinit var clientSecret: String

@Value("\${oauth.google.redirect-uri}")
lateinit var redirectUri: String

@Value("\${oauth.google.client-name}")
lateinit var clientName: String

@Value("\${oauth.google.base-url}")
lateinit var baseUrl: String

@Value("\${oauth.google.scope}")
lateinit var scope: String

@Value("\${oauth.google.oauth-url}")
lateinit var oauthUrl: String

@Value("\${oauth.google.api-url}")
lateinit var apiUrl: String

var grantType = "authorization_code"

fun oauthUrl(): String =
baseUrl +
"?client_id=$clientId" +
"&redirect_uri=${java.net.URLEncoder.encode(redirectUri, "UTF-8")}" +
"&response_type=code" +
"&scope=${scope.replace(",", "%20")}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.backgu.amaker.auth.controller

import com.backgu.amaker.auth.config.AuthConfig
import com.backgu.amaker.auth.service.AuthService
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam

@Controller
@RequestMapping("/auth")
class AuthController(
val authConfig: AuthConfig,
val authService: AuthService,
) {
@GetMapping("/oauth/google")
fun googleAuth(response: HttpServletResponse) {
response.sendRedirect(authConfig.oauthUrl())
}

@GetMapping("/code/google")
fun login(
@RequestParam(name = "code") authorizationCode: String,
@RequestParam(name = "scope", required = false) scope: String,
@RequestParam(name = "authuser", required = false) authUser: String,
@RequestParam(name = "prompt", required = false) prompt: String,
): String {
authService.googleLogin(authorizationCode)
return "redirect:/"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.backgu.amaker.auth.dto

import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
class GoogleOAuth2AccessTokenDto(
val accessToken: String?,
val expiresIn: Int?,
val idToken: String?,
val scope: String?,
val tokenType: String?,
) {
fun getBearerToken(): String = "Bearer $accessToken"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.backgu.amaker.auth.dto

import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
class GoogleUserInfoDto(
val id: String?,
val email: String?,
val verifiedEmail: Boolean?,
val name: String?,
val givenName: String?,
val picture: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.backgu.amaker.auth.infra

import com.backgu.amaker.auth.dto.GoogleUserInfoDto
import com.backgu.amaker.config.CaughtHttpExchange
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.service.annotation.GetExchange

@CaughtHttpExchange
interface GoogleApiClient {
@GetExchange("/oauth2/v2/userinfo")
fun getUserInfo(
@RequestHeader("Authorization") authorization: String,
): GoogleUserInfoDto?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.backgu.amaker.auth.infra

import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto
import com.backgu.amaker.config.CaughtHttpExchange
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.service.annotation.PostExchange

@CaughtHttpExchange
interface GoogleOAuthClient {
@PostExchange("/token", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
fun getGoogleOAuth2(
@RequestParam("code") authCode: String,
@RequestParam("redirect_uri") redirectUri: String,
@RequestParam("grant_type") grantType: String,
@RequestParam("client_secret") clientSecret: String,
@RequestParam("client_id") clientId: String,
): GoogleOAuth2AccessTokenDto?
}
33 changes: 33 additions & 0 deletions api/src/main/kotlin/com/backgu/amaker/auth/service/AuthService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.backgu.amaker.auth.service

import com.backgu.amaker.auth.config.AuthConfig
import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto
import com.backgu.amaker.auth.dto.GoogleUserInfoDto
import com.backgu.amaker.auth.infra.GoogleApiClient
import com.backgu.amaker.auth.infra.GoogleOAuthClient
import org.springframework.stereotype.Service
import java.lang.IllegalArgumentException

@Service
class AuthService(
val googleOAuthClient: GoogleOAuthClient,
val googleApiClient: GoogleApiClient,
val authConfig: AuthConfig,
) {
fun googleLogin(authorizationCode: String): String? {
val accessTokenDto: GoogleOAuth2AccessTokenDto =
googleOAuthClient.getGoogleOAuth2(
authorizationCode,
authConfig.redirectUri,
authConfig.grantType,
authConfig.clientSecret,
authConfig.clientId,
) ?: throw IllegalArgumentException("Failed to get access token")

val userInfo: GoogleUserInfoDto =
googleApiClient.getUserInfo(accessTokenDto.getBearerToken())
?: throw IllegalArgumentException("Failed to get user information")

return userInfo.email
}
}
8 changes: 8 additions & 0 deletions api/src/main/kotlin/com/backgu/amaker/config/AppConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.backgu.amaker.config

import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.EnableAspectJAutoProxy

@Configuration
@EnableAspectJAutoProxy
class AppConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.backgu.amaker.config

import org.springframework.web.service.annotation.HttpExchange

@HttpExchange
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class CaughtHttpExchange
23 changes: 23 additions & 0 deletions api/src/main/kotlin/com/backgu/amaker/config/RestClientAspect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.backgu.amaker.config

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Pointcut
import org.springframework.stereotype.Component

@Aspect
@Component
class RestClientAspect {
@Pointcut("within(@CaughtHttpExchange *)")
fun caughtHttpExchange() {
}

@Around("caughtHttpExchange()")
fun handleException(jointPoint: ProceedingJoinPoint): Any? =
try {
jointPoint.proceed()
} catch (e: Exception) {
null
}
}
33 changes: 33 additions & 0 deletions api/src/main/kotlin/com/backgu/amaker/config/RestClientConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.backgu.amaker.config

import com.backgu.amaker.auth.config.AuthConfig
import com.backgu.amaker.auth.infra.GoogleApiClient
import com.backgu.amaker.auth.infra.GoogleOAuthClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory

@Configuration
class RestClientConfig(
val authConfig: AuthConfig,
) {
@Bean
fun googleOauth2Service(): GoogleOAuthClient {
val restClient = RestClient.builder().baseUrl(authConfig.oauthUrl).build()
val adapter = RestClientAdapter.create(restClient)
val factory = HttpServiceProxyFactory.builderFor(adapter).build()

return factory.createClient(GoogleOAuthClient::class.java)
}

@Bean
fun googleApiService(): GoogleApiClient {
val restClient = RestClient.builder().baseUrl(authConfig.apiUrl).build()
val adapter = RestClientAdapter.create(restClient)
val factory = HttpServiceProxyFactory.builderFor(adapter).build()

return factory.createClient(GoogleApiClient::class.java)
}
}
13 changes: 12 additions & 1 deletion api/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,15 @@ spring:
jpa:
database: mysql
hibernate:
ddl-auto: create
ddl-auto: create

oauth:
google:
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
redirect-uri: "http://localhost:8080/auth/code/google"
base-url: "https://accounts.google.com/o/oauth2/auth"
oauth-url: "https://oauth2.googleapis.com"
api-url: "https://www.googleapis.com"
client-name: Google
scope: email,profile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.backgu.amaker.auth.service

import com.backgu.amaker.auth.infra.GoogleApiClient
import com.backgu.amaker.auth.infra.GoogleOAuthClient
import com.backgu.amaker.auth.test.FailedFakeGoogleApiClient
import com.backgu.amaker.auth.test.FailedFakeGoogleOAuthClient
import com.backgu.amaker.auth.test.SuccessfulStubGoogleApiClient
import com.backgu.amaker.auth.test.SuccessfulStubGoogleOAuthClient
import com.backgu.amaker.fixture.AuthFixture
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test

class AuthServiceTest {
private lateinit var authService: AuthService
private lateinit var googleOAuthClient: GoogleOAuthClient
private lateinit var googleApiClient: GoogleApiClient

@Test
@DisplayName("구글 성공 로그인 테스트")
fun successfulGoogleLoginTest() {
// given
val email = "[email protected]"
googleOAuthClient = SuccessfulStubGoogleOAuthClient()
googleApiClient = SuccessfulStubGoogleApiClient(email)
authService = AuthService(googleOAuthClient, googleApiClient, AuthFixture.createUserRequest())

// when
val result = authService.googleLogin("authCode")

// then
assertThat(result).isEqualTo(email)
}

@Test
@DisplayName("구글 oauth 서버에서 토큰 획득 실패 테스트")
fun failedToGetAccessTokenTest() {
// given
val email = "[email protected]"
googleOAuthClient = FailedFakeGoogleOAuthClient()
googleApiClient = SuccessfulStubGoogleApiClient(email)
authService = AuthService(googleOAuthClient, googleApiClient, AuthFixture.createUserRequest())

// when
// then
assertThatThrownBy { authService.googleLogin("authCode") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessage("Failed to get access token")
}

@Test
@DisplayName("구글 oauth 서버에서 토큰 획득 실패 테스트")
fun failedToUserInfo() {
// given
val email = "[email protected]"
googleOAuthClient = SuccessfulStubGoogleOAuthClient()
googleApiClient = FailedFakeGoogleApiClient()
authService = AuthService(googleOAuthClient, googleApiClient, AuthFixture.createUserRequest())

// when
// then
assertThatThrownBy { authService.googleLogin("authCode") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessage("Failed to get user information")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.backgu.amaker.auth.test

import com.backgu.amaker.auth.dto.GoogleUserInfoDto
import com.backgu.amaker.auth.infra.GoogleApiClient

class FailedFakeGoogleApiClient : GoogleApiClient {
override fun getUserInfo(authorization: String): GoogleUserInfoDto? = throw IllegalArgumentException("Failed to get user information")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.backgu.amaker.auth.test

import com.backgu.amaker.auth.dto.GoogleOAuth2AccessTokenDto
import com.backgu.amaker.auth.infra.GoogleOAuthClient

class FailedFakeGoogleOAuthClient : GoogleOAuthClient {
override fun getGoogleOAuth2(
authCode: String,
redirectUri: String,
grantType: String,
clientSecret: String,
clientId: String,
): GoogleOAuth2AccessTokenDto? {
throw IllegalArgumentException("Failed to get access token")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.backgu.amaker.auth.test

import com.backgu.amaker.auth.dto.GoogleUserInfoDto
import com.backgu.amaker.auth.infra.GoogleApiClient

class SuccessfulStubGoogleApiClient(
email: String,
) : GoogleApiClient {
private val googleUserInfo: GoogleUserInfoDto =
GoogleUserInfoDto(
id = "stubId",
email = email,
verifiedEmail = true,
name = "stubName",
givenName = "stubGivenName",
picture = "stubPicture",
)

override fun getUserInfo(authorization: String): GoogleUserInfoDto = googleUserInfo
}
Loading

0 comments on commit ada0d24

Please sign in to comment.