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

[YS-31] feat: 구글 OAuth 로그인 구현 #13

Merged
merged 21 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
56683ae
feat: define domain models and enums for the project
chock-cho Dec 28, 2024
d328163
Merge branch 'dev' of https://github.com/YAPP-Github/25th-Web-Team-2-…
chock-cho Dec 29, 2024
b628066
refact: update domain structures for refactoring
chock-cho Dec 30, 2024
2c55b09
fix: fixing some errors(Member, AuditingEntity)
chock-cho Dec 30, 2024
7224c75
feat: 구글 OAuth 로그인 구현
chock-cho Dec 30, 2024
8389202
fix: add dotenv libraries
chock-cho Dec 31, 2024
a90ec1b
fix: delete import which occurs dependency error
chock-cho Dec 31, 2024
167c73d
fix: resolve context loading issue with active test profile
chock-cho Dec 31, 2024
6d7ecbe
Merge branch 'dev' of https://github.com/YAPP-Github/25th-Web-Team-2-…
chock-cho Dec 31, 2024
995bbfc
Merge branch 'dev' of https://github.com/YAPP-Github/25th-Web-Team-2-…
chock-cho Dec 31, 2024
919cf9a
feat: implement google oauth login
chock-cho Jan 1, 2025
260fcbe
refact: seperate responsibilities according to Clean Architecture
chock-cho Jan 2, 2025
6f465bc
test: test code for Google OAuth login logic, AuditingEntity
chock-cho Jan 2, 2025
91ad19d
test: add OauthMapperTest and MemberServiceTest for refact test coverage
chock-cho Jan 2, 2025
fe4ba57
Merge branch 'dev' of https://github.com/YAPP-Github/25th-Web-Team-2-…
chock-cho Jan 2, 2025
d9598f6
feat : add Google OAuth integration
chock-cho Jan 2, 2025
cf8d7a2
fix: add dependency code and custom exception code
chock-cho Jan 2, 2025
85fa57f
fix: adjust things to build safely
chock-cho Jan 2, 2025
eaa7a47
refact: update test codes to satisfy the newly requirement
chock-cho Jan 2, 2025
e558b11
refact: reflect pr reviews and update changes
chock-cho Jan 2, 2025
0c10ed2
refact: reflect code from reviews
chock-cho Jan 3, 2025
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
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()
Comment on lines 30 to 42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 컬럼 규칙이 바뀌면 dev DB의 테이블도 이에 맞게 업데이트되어야 할 것 같습니다. 현재 DB에 데이터가 쌓이지 않은 상태라, 머지 전에 기존 테이블(Member, Participant, Researcher을 drop한 후 새로 업데이트된 테이블 정보로 create하면 문제없이 진행될 것 같아요! 😊

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
Loading