Skip to content

Commit

Permalink
[YS-31] feat: 구글 OAuth 로그인 구현 (#13)
Browse files Browse the repository at this point in the history
* feat: define domain models and enums for the project

- 프로젝트의 핵심 데이터를 표현하는 엔티티 클래스 정의
- 표준화를 위한 enum 클래스 구현
- 프로젝트 확장성과 명확성을 위한 기본 도메인 설계 완료

* refact: update domain structures for refactoring

- `AuditingEntityListener` 추가
- 기존: 리스트 순차 탐색 ➡️  Map의 key값으로 탐색 개선
- 패키지명 `enums` ➡️  `enum` 으로 변경

* fix: fixing some errors(Member, AuditingEntity)

- `AuditingEntity` 변경 내용 추가
- `Member` 의존성 알맞게 주입

* feat: 구글 OAuth 로그인 구현

- Google OAuth 로그인 구현
- 추후 회원가입 로직의 OAuth 정보 받아오는 과정 고려하여, 도메인 클래스
  일부 수정
- `.env` 파일 로드를 위한 의존성 파일 추가

* fix: add dotenv libraries

- 에러 수정을 위해 빌드 파일에 `dotenv` 라이브러리 추가

* fix: delete import which occurs dependency error

* fix: resolve context loading issue with active test profile

- 테스트 중 적절한 빈 구성 보장을 위해 `@ActiveProfiles("test")` 추가
- `SecurityFilterChain` 관련 Application Context 로딩 오류 해결

* feat: implement google oauth login

- 구글 oauth 로그인 구현
- 구글 oauth token, email, name 정보 가져오도록 설정

* refact: seperate responsibilities according to Clean Architecture
principal

- Google API 호출 로직을 `FetchGoogleUserInfoUseCase`로 이동하여 책임 분리
  개선
- `OauthService` 를 수정하여 사용자 정보 조회를 새로운 UseCase로 위임

* test: test code for Google OAuth login logic, AuditingEntity

- 구글 OAuth 로직에 대한 test code 작성: `OauthServiceTest`
  `FetchGoogleUserInfoTest`
- `AuditingEntityTest` 에 대한 test code 작성

- JaCoco 커버리지 테스트에서 AuditingEntityTest 커버리지 0.5로 목표치
  미달 → 추후 테스트 커버리지 개선 필요

* test: add OauthMapperTest and MemberServiceTest for refact test coverage

- `OauthMapperTest` 테스트 코드 추가
- `MemberServiceTest` 테스트 코드 추가

- `AuditingEntityTest` → 테스트 커버리지 50%로 목표치 70% 달성 실패,
  추후 개선 필요

* feat : add Google OAuth integration

- Google OAuth 연동을 위한 `GoogleAuthFeignClient` 추가
- 사용자 정보를 가져오는 `GoogleUserInfoFeignClient` 구현
- GoogleTokenRequest 및 GoogleTokenResponse DTO 정의
- OauthLoginResponse에 사용자 정보 및 accessToken, refreshToken 포함
- 기존 WebClientConfig 삭제 → FeignClient로 대체

* fix: add dependency code and custom exception code

- Google OAuth 로그인 시도 시 발생할 수 있는 예외처리
- Application에 FeignClient 설정

* fix: adjust things to build safely

* refact: update test codes to satisfy the newly requirement

* refact: reflect pr reviews and update changes

- 중복된 선언: `@Repository` 어노테이션 제거
- SwaggerConfig 가독성 개선

* refact: reflect code from reviews

- 빌드 파일에 중복된 의존성 제거
- `OAuthController` 제거 → `AuthController` 로 통일
- 파일 끝에 EOF 추가
  • Loading branch information
chock-cho authored Jan 3, 2025
1 parent c88d379 commit dc4d52e
Show file tree
Hide file tree
Showing 38 changed files with 790 additions and 32 deletions.
13 changes: 12 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,20 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("com.github.f4b6a3:ulid-creator:5.2.3")
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.3")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.github.cdimascio:java-dotenv:5.2.2")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation ("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0")
compileOnly("org.projectlombok:lombok")
runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("com.h2database:h2")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.13.10")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand All @@ -63,6 +68,12 @@ dependencies {
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")
}

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
}
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
Expand Down
9 changes: 9 additions & 0 deletions src/main/kotlin/com/dobby/backend/DobbyBackendApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ package com.dobby.backend

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.cloud.openfeign.EnableFeignClients

@SpringBootApplication
@ConfigurationPropertiesScan
@EnableFeignClients
class DobbyBackendApplication

fun main(args: Array<String>) {
val dotenv = Dotenv.configure().load()
dotenv.entries().forEach{ entry ->
System.setProperty(entry.key, entry.value)
}
runApplication<DobbyBackendApplication>(*args)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.dobby.backend.application.mapper

import com.dobby.backend.infrastructure.database.entity.Member
import com.dobby.backend.infrastructure.database.entity.enum.MemberStatus
import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import com.dobby.backend.infrastructure.database.entity.enum.RoleType
import com.dobby.backend.presentation.api.dto.request.OauthUserDto
import com.dobby.backend.presentation.api.dto.response.OauthLoginResponse

object OauthUserMapper {
fun toDto(
isRegistered: Boolean,
accessToken: String,
refreshToken: String,
oauthEmail: String,
oauthName: String,
role: RoleType,
provider: ProviderType,
memberId: Long? = null
): OauthLoginResponse {
return OauthLoginResponse(
isRegistered = isRegistered,
accessToken = accessToken,
refreshToken = refreshToken,
memberInfo = OauthLoginResponse.MemberInfo(
memberId = memberId,
oauthEmail = oauthEmail,
name = oauthName,
role = role,
provider = provider
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.FetchGoogleUserInfoUseCase
import com.dobby.backend.presentation.api.dto.request.OauthLoginRequest
import com.dobby.backend.presentation.api.dto.response.OauthLoginResponse
import org.springframework.stereotype.Service

@Service
class OauthService(
private val fetchGoogleUserInfoUseCase: FetchGoogleUserInfoUseCase
) {
fun getGoogleUserInfo(oauthLoginRequest: OauthLoginRequest): OauthLoginResponse {
return fetchGoogleUserInfoUseCase.execute(oauthLoginRequest)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.dobby.backend.application.usecase

import com.dobby.backend.application.mapper.OauthUserMapper
import com.dobby.backend.domain.exception.OAuth2EmailNotFoundException
import com.dobby.backend.domain.exception.OAuth2ProviderMissingException
import com.dobby.backend.domain.exception.SignInMemberException
import com.dobby.backend.infrastructure.config.properties.GoogleAuthProperties
import com.dobby.backend.infrastructure.database.entity.enum.MemberStatus
import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import com.dobby.backend.infrastructure.database.repository.MemberRepository
import com.dobby.backend.infrastructure.feign.GoogleAuthFeignClient
import com.dobby.backend.infrastructure.feign.GoogleUserInfoFeginClient
import com.dobby.backend.infrastructure.token.JwtTokenProvider
import com.dobby.backend.presentation.api.dto.request.GoogleTokenRequest
import com.dobby.backend.presentation.api.dto.request.OauthLoginRequest
import com.dobby.backend.presentation.api.dto.response.GoogleTokenResponse
import com.dobby.backend.presentation.api.dto.response.OauthLoginResponse
import com.dobby.backend.util.AuthenticationUtils
import feign.FeignException
import org.springframework.stereotype.Component

@Component
class FetchGoogleUserInfoUseCase(
private val googleAuthFeignClient: GoogleAuthFeignClient,
private val googleUserInfoFeginClient: GoogleUserInfoFeginClient,
private val jwtTokenProvider: JwtTokenProvider,
private val googleAuthProperties: GoogleAuthProperties,
private val memberRepository: MemberRepository
){
fun execute(oauthLoginRequest: OauthLoginRequest) : OauthLoginResponse{
try {
val googleTokenRequest = GoogleTokenRequest(
code = oauthLoginRequest.authorizationCode,
clientId = googleAuthProperties.clientId,
clientSecret = googleAuthProperties.clientSecret,
redirectUri = googleAuthProperties.redirectUri
)

val oauthRes = fetchAccessToken(googleTokenRequest)
val oauthToken = oauthRes.accessToken

val userInfo = googleUserInfoFeginClient.getUserInfo("Bearer $oauthToken")
val email = userInfo.email as? String?: throw OAuth2EmailNotFoundException()
val regMember = memberRepository.findByOauthEmailAndStatus(email, MemberStatus.ACTIVE)
?: throw SignInMemberException()

val regMemberAuthentication = AuthenticationUtils.createAuthentication(regMember)
val jwtAccessToken = jwtTokenProvider.generateAccessToken(regMemberAuthentication)
val jwtRefreshToken = jwtTokenProvider.generateRefreshToken(regMemberAuthentication)

return OauthUserMapper.toDto(
isRegistered = true,
accessToken = jwtAccessToken,
refreshToken = jwtRefreshToken,
oauthEmail = regMember.oauthEmail,
oauthName = regMember.name?: throw SignInMemberException(),
role = regMember.role?: throw SignInMemberException(),
provider = ProviderType.GOOGLE
)
} catch (e : FeignException) {
throw OAuth2ProviderMissingException()
}
}

private fun fetchAccessToken(googleTokenRequest: GoogleTokenRequest): GoogleTokenResponse {
return googleAuthFeignClient.getAccessToken(googleTokenRequest)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,3 @@ class AuthenticationTokenNotFoundException : AuthenticationException(ErrorCode.T
class AuthenticationTokenNotValidException : AuthenticationException(ErrorCode.TOKEN_NOT_VALID)
class AuthenticationTokenExpiredException : AuthenticationException(ErrorCode.TOKEN_EXPIRED)
class InvalidTokenTypeException : AuthenticationException(ErrorCode.INVALID_TOKEN_TYPE)

20 changes: 20 additions & 0 deletions src/main/kotlin/com/dobby/backend/domain/exception/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,24 @@ enum class ErrorCode(
* Member error codes
*/
MEMBER_NOT_FOUND("ME0001", "Member not found", HttpStatus.NOT_FOUND),

/**
* OAuth2 error codes
*/
OAUTH_USER_NOT_FOUND("OA001", "Authentication Principal is not an instance of OAuth2User", HttpStatus.BAD_REQUEST),
OAUTH_PROVIDER_MISSING("OA002", "Cannot Load OAuth2 User Info during the external API calling", HttpStatus.BAD_REQUEST),
OAUTH_PROVIDER_NOT_FOUND("OA003", "Not supported Provider is requested", HttpStatus.BAD_REQUEST),
OAUTH_EMAIL_NOT_FOUND("OA004", "Email cannot found in OAuth2 Authentication Info", HttpStatus.BAD_REQUEST),
OAUTH_NAME_NOT_FOUND("OA005", "Name cannt found in OAuth2 Authentication Info", HttpStatus.BAD_REQUEST),

/**
* Signup error codes
*/
SIGNUP_ALREADY_MEMBER("SIGN_UP_001", "You've already joined", HttpStatus.CONFLICT),

/**
* Signin error codes
*/
SIGNIN_MEMBER_NOT_FOUND("SIGN_IN_001", "Member Not Found. Please signup. (Redirect URI must be /v1/auth/{role}/signup)", HttpStatus.NOT_FOUND),
SIGNIN_ROLE_MISMATCH("SIGN_IN_002", "Already registered member as %s. Please login as %s", HttpStatus.BAD_REQUEST)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ open class MemberException(
) : DomainException(errorCode)

class MemberNotFoundException : MemberException(ErrorCode.MEMBER_NOT_FOUND)
class AlreadyMemberException: MemberException(ErrorCode.SIGNUP_ALREADY_MEMBER)
class SignInMemberException: MemberException(ErrorCode.SIGNIN_MEMBER_NOT_FOUND)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dobby.backend.domain.exception

open class OauthException (
errorCode: ErrorCode,
) : DomainException(errorCode)
class OAuth2AuthenticationException: OauthException(ErrorCode.OAUTH_USER_NOT_FOUND)
class OAuth2ProviderMissingException: OauthException(ErrorCode.OAUTH_PROVIDER_MISSING)
class OAuth2ProviderNotSupportedException: OauthException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND)
class OAuth2EmailNotFoundException : OauthException(ErrorCode.OAUTH_EMAIL_NOT_FOUND)
class OAuth2NameNotFoundException : OauthException(ErrorCode.OAUTH_NAME_NOT_FOUND)

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dobby.backend.domain.exception

import com.dobby.backend.infrastructure.database.entity.enum.RoleType
import org.springframework.http.HttpStatus

class SignInRoleMismatchException(
registeredRole: RoleType?,
requestedRole: RoleType
) : RuntimeException(
String.format(
ErrorCode.SIGNIN_ROLE_MISMATCH.message,
registeredRole?.name,
requestedRole.name
)
) {
val code: String = ErrorCode.SIGNIN_ROLE_MISMATCH.code
val status: HttpStatus = ErrorCode.SIGNIN_ROLE_MISMATCH.httpStatus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.dobby.backend.infrastructure.config.properties

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component

@Component
@ConfigurationProperties(prefix = "spring.security.oauth.client.registration.google")
data class GoogleAuthProperties (
var clientId: String = "",
var clientSecret: String= "",
var redirectUri : String = ""
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ open class Member (

@Column(name = "role", nullable = true)
@Enumerated(EnumType.STRING)
val role: RoleType,
val role: RoleType?,

@Column(name = "contact_email", length = 100, nullable = false)
val contactEmail : String,
@Column(name = "contact_email", length = 100, nullable = true)
val contactEmail : String?,

@Column(name = "name", length = 10, nullable = false)
val name : String,
@Column(name = "name", length = 10, nullable = true)
val name : String?,

@Column(name = "birth_date", nullable = false)
val birthDate : LocalDate,
@Column(name = "birth_date", nullable = true)
val birthDate : LocalDate?,
) : AuditingEntity()
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import com.dobby.backend.infrastructure.database.entity.enum.RoleType
import com.dobby.backend.infrastructure.database.entity.enum.areaInfo.Area
import com.dobby.backend.infrastructure.database.entity.enum.areaInfo.Region
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.*
import java.time.LocalDate

@Entity(name = "participant")
@DiscriminatorValue("PARTICIPANT")
class Participant (
@OneToOne
@JoinColumn(name = "member_id", nullable = false)
val member: Member,

@Column(name = "gender", nullable = false)
@Enumerated(EnumType.STRING)
val gender: GenderType,
Expand All @@ -30,15 +30,15 @@ class Participant (

@Column(name = "optional_region", nullable = true)
@Enumerated(EnumType.STRING)
var optionalRegion: Region,
var optionalRegion: Region?,

@Column(name = "optional_area", nullable = true)
@Enumerated(EnumType.STRING)
var optionalArea: Area,
var optionalArea: Area?,

@Column(name = "prefer_type", nullable = true)
@Enumerated(EnumType.STRING)
var preferType: MatchType,
var preferType: MatchType?,

id: Long,
oauthEmail: String,
Expand All @@ -55,4 +55,3 @@ class Participant (
name = name,
birthDate = birthDate
)

Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ package com.dobby.backend.infrastructure.database.entity

import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import com.dobby.backend.infrastructure.database.entity.enum.RoleType
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.*
import java.time.LocalDate

@Entity(name = "researcher")
@DiscriminatorValue("RESEARCHER")
class Researcher (
@OneToOne
@JoinColumn(name = "member_id", nullable = false)
val member: Member,

@Column(name = "univ_email", length = 100, nullable = false)
val univEmail : String,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dobby.backend.infrastructure.database.repository

import com.dobby.backend.infrastructure.database.entity.Member
import com.dobby.backend.infrastructure.database.entity.enum.MemberStatus
import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

interface MemberRepository : JpaRepository<Member, Long> {
fun findByOauthEmailAndStatus(oauthEmail: String, status: MemberStatus): Member?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.dobby.backend.infrastructure.database.repository

import com.dobby.backend.infrastructure.database.entity.Participant
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

interface ParticipantRepository: JpaRepository<Participant, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dobby.backend.infrastructure.feign

import com.dobby.backend.presentation.api.dto.request.GoogleTokenRequest
import com.dobby.backend.presentation.api.dto.response.GoogleTokenResponse
import feign.Headers
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam

@FeignClient(
name = "google-auth-feign-client",
url = "https://oauth2.googleapis.com",
)
interface GoogleAuthFeignClient {
@PostMapping("/token")
fun getAccessToken(@RequestBody googleTokenRequest: GoogleTokenRequest
): GoogleTokenResponse
}
Loading

0 comments on commit dc4d52e

Please sign in to comment.