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 15 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
7 changes: 6 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-oauth2-client")
Copy link
Member

Choose a reason for hiding this comment

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

같은 dependency가 두 번 추가된 것 같습니다! 그리고 이제 FeignClient 라이브러리를 사용해서 구현할 예정인데, 이 라이브러리도 필요할지 궁금합니다.

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 ("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 Down
5 changes: 5 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,15 @@ package com.dobby.backend

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import io.github.cdimascio.dotenv.Dotenv;

@SpringBootApplication
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,33 @@
package com.dobby.backend.application.mapper

import com.dobby.backend.domain.exception.OAuth2EmailNotFoundException
import com.dobby.backend.domain.exception.OAuth2NameNotFoundException
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.presentation.api.dto.request.OauthUserDto
import org.springframework.security.oauth2.core.user.OAuth2User

object OauthUserMapper {
fun toDto(oauthUser: OAuth2User, provider: ProviderType): OauthUserDto {
val email = oauthUser.getAttribute<String>("email")
?: throw OAuth2EmailNotFoundException()
val name = oauthUser.getAttribute<String>("name")
?: throw OAuth2NameNotFoundException()

return OauthUserDto(email=email, name=name, provider= provider)
}

fun toTempMember(dto: OauthUserDto): Member {
return Member(
id = 0L,
oauthEmail= dto.email,
name= dto.name,
provider = dto.provider,
status = MemberStatus.HOLD,
role = null,
contactEmail = null,
birthDate = null
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dobby.backend.application.service

import com.dobby.backend.domain.exception.MemberNotFoundException
import com.dobby.backend.infrastructure.database.entity.Member
import com.dobby.backend.infrastructure.database.entity.enum.MemberStatus
import com.dobby.backend.infrastructure.database.repository.MemberRepository
import com.dobby.backend.infrastructure.token.JwtTokenProvider
import com.dobby.backend.presentation.api.dto.request.OauthUserDto
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service

@Service
class MemberService(
private val memberRepository: MemberRepository,
private val jwtTokenProvider: JwtTokenProvider
) {
@Transactional
fun login(oauthUserDto: OauthUserDto): Member{
val member= memberRepository.findByOauthEmailAndStatus(oauthUserDto.email, MemberStatus.ACTIVE)
?: throw MemberNotFoundException()
return member
}
}
Copy link
Member

Choose a reason for hiding this comment

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

제가 이해한 바로는, 클린 아키텍처에서 중요한 DIP을 지키기 위해서는 서비스 계층을 추상화하는 것이 올바른 접근이라고 생각합니다.
Service 인터페이스를 정의하고, 실제 구현체는 infrastructure 계층에서 구현하도록 하여 application 계층이 infrastructure에 의존하지 않도록 해야 합니다. 다만, 이렇게 구현하면 파일이 많아지는 단점이 있습니다.

따라서, 다음 두 가지 방식 중에서 어떤 것이 더 적합할지 고민해볼 필요가 있을 거 같습니다.

  1. 클린 아키텍처 원칙에 따라 추상화를 철저히 한다.
  2. 융통성 있게 상황에 맞게 조정한다.

수정님은 어떤 방향이 더 낫다고 생각하나요?

Copy link
Member

Choose a reason for hiding this comment

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

1/2 서버 회의에서는 추상화를 철저히 하지만, 이번 주 주말 해커톤까지는 MVP를 빠르게 개발하는 것이 우선이므로, 추후 리팩터링을 통해 개선하기로 결정했습니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.FetchGoogleUserInfoUseCase
import com.dobby.backend.domain.exception.OAuth2EmailNotFoundException
import com.dobby.backend.domain.exception.OAuth2NameNotFoundException
import com.dobby.backend.infrastructure.database.entity.enum.ProviderType
import com.dobby.backend.presentation.api.dto.response.OauthTokenResponse
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient

@Service
class OauthService(
private val fetchGoogleUserInfoUseCase: FetchGoogleUserInfoUseCase
) {
fun getGoogleUserInfo(accessToken: String): OauthTokenResponse {
val userInfo = fetchGoogleUserInfoUseCase.execute(accessToken)

val email = userInfo["email"] as? String ?: throw OAuth2EmailNotFoundException()
val name = userInfo["name"] as? String ?: throw OAuth2NameNotFoundException()
return OauthTokenResponse(
jwtToken= accessToken,
email = email,
name = name,
provider = ProviderType.GOOGLE
)
}
}
Copy link
Member

Choose a reason for hiding this comment

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

엔터 라인 한 번 쳐주세요~

Copy link
Contributor Author

Choose a reason for hiding this comment

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

반영하겠습니다!
앞으로 작업 전에 EOF 문제 있는지까지 꼼꼼히 처리해야겠네요!

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

import com.dobby.backend.domain.exception.OAuth2ProviderMissingException
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.reactive.function.client.bodyToMono

@Component
class FetchGoogleUserInfoUseCase(
private val webClientBuilder : WebClient.Builder
){
fun execute(accessToken: String) : Map<String, Any>{
try {
val webClient = webClientBuilder.build()
return webClient.get()
.uri("https://www.googleapis.com/oauth2/v3/userinfo")
.header("Authorization", "Bearer $accessToken")
.retrieve()
.bodyToMono<Map<String, Any>>()
.block() ?: throw OAuth2ProviderMissingException()
} catch (e : WebClientResponseException) {
throw OAuth2ProviderMissingException()
}
}
}
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)

14 changes: 14 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,18 @@ 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("SI001", "You've already joined", HttpStatus.CONFLICT)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ open class MemberException(
) : DomainException(errorCode)

class MemberNotFoundException : MemberException(ErrorCode.MEMBER_NOT_FOUND)
class AlreadyMemberException: MemberException(ErrorCode.SIGNUP_ALREADY_MEMBER)
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
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,12 @@
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

@Repository
Copy link
Member

Choose a reason for hiding this comment

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

사소한 부분이지만, JpaRepository를 상속받으면 자동으로 빈으로 등록되기 때문에 @Repository를 생략해도 괜찮습니다.

개인적으로는 @Repository를 추가하는 것이 중복된 선언이라 생각해 생략하는 편인데, 명시적으로 표현하려는 의도라면 추가하는 것도 좋다고 생각해요. 서로 컨벤션을 맞춰야 할 거 같은데 혹시 수정님은 @Repository를 붙이시는 이유가 명시성을 위해서일까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

명시성을 위해서였는데, @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,9 @@
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

@Repository
interface ParticipantRepository: JpaRepository<Participant, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import io.swagger.v3.oas.annotations.info.Info
import io.swagger.v3.oas.annotations.servers.Server
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import io.swagger.v3.oas.models.security.*
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

Expand All @@ -28,7 +27,33 @@ class SwaggerConfig {
@Bean
fun openAPI(): OpenAPI = OpenAPI()
.addSecurityItem(SecurityRequirement().addList("JWT 토큰"))
.addSecurityItem(SecurityRequirement().addList("Google OAuth2 토큰"))
.components(
Components().addSecuritySchemes("JWT 토큰",
SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT")))
Components()
.addSecuritySchemes(
"JWT 토큰",
SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("Bearer")
.bearerFormat("JWT")
)
.addSecuritySchemes(
"Google OAuth2 토큰",
SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.flows(
OAuthFlows()
.authorizationCode(
OAuthFlow()
.authorizationUrl("https://accounts.google.com/o/oauth2/auth")
.tokenUrl("https://oauth2.googleapis.com/token")
.scopes(
Scopes()
.addString("email", "Access your email address")
.addString("profile", "Access your profile information")
)
)
)
)
)
Copy link
Member

Choose a reason for hiding this comment

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

val googleAuthUrl = "https://accounts.google.com/o/oauth2/auth"
val googleTokenUrl = "https://oauth2.googleapis.com/token"

val jwtScheme = SecurityScheme()
    .type(SecurityScheme.Type.HTTP)
    .scheme("Bearer")
    .bearerFormat("JWT")

val googleOAuthScheme = SecurityScheme()
    .type(SecurityScheme.Type.OAUTH2)
    .flows(
        OAuthFlows().authorizationCode(
            OAuthFlow()
                .authorizationUrl(googleAuthUrl)
                .tokenUrl(googleTokenUrl)
                .scopes(
                    Scopes()
                        .addString("email", "Access your email address")
                        .addString("profile", "Access your profile information")
                )
        )
    )

@Bean
fun openAPI(): OpenAPI = OpenAPI()
    .addSecurityItem(SecurityRequirement().addList("JWT 토큰").addList("Google OAuth2 토큰"))
    .components(
        Components()
            .addSecuritySchemes("JWT 토큰", jwtScheme)
            .addSecuritySchemes("Google OAuth2 토큰", googleOAuthScheme)
    )

중첩된 부분을 위와 같이 별도 변수로 나누면 가독성이 좋아질 거 같습니다! 혹시 어떠신가요??

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dobby.backend.presentation.api.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.WebClient

@Configuration
class WebClientConfig {
@Bean
fun webClientBuilder(): WebClient.Builder {
return WebClient.builder()
}
}
Loading
Loading