Skip to content

Commit

Permalink
Merge pull request #4 from EntryDSM/feature/2-develop-github-oauth
Browse files Browse the repository at this point in the history
Feature/2 develop GitHub oauth
  • Loading branch information
kangeunchan authored Dec 21, 2024
2 parents 75e2b73 + fe5fa8e commit 44b5d70
Show file tree
Hide file tree
Showing 24 changed files with 533 additions and 47 deletions.
40 changes: 0 additions & 40 deletions build.gradle

This file was deleted.

49 changes: 49 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.25"
id("org.jetbrains.kotlin.plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.0"
id("io.spring.dependency-management") version "1.1.6"
id("org.jetbrains.kotlin.plugin.jpa") version "1.9.25"
}

group = "entry.dsm.gitauth"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
runtimeOnly("com.mysql:mysql-connector-j")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.2.0")
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}

noArg {
annotation("jakarta.persistence.Entity")
}

tasks.test {
useJUnitPlatform()
}
5 changes: 2 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
```mermaid
sequenceDiagram
participant User as User
participant App as Equus-Github-Auth System
participant App as Casper-Internship
participant GitHub as GitHub
User ->> App: Login Request
Expand All @@ -21,5 +21,4 @@ sequenceDiagram
else User is not a member of EntryDSM
App ->> User: Login Fail
end
```

```
5 changes: 2 additions & 3 deletions scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
```mermaid
erDiagram
USERS {
BIGINT id PK "고유 ID"
BIGINT github_id "GitHub 사용자 ID"
UUID id PK "고유 ID"
VARCHAR github_id "GitHub 사용자 ID"
VARCHAR username "GitHub 사용자 이름"
VARCHAR email "사용자 이메일"
VARCHAR profile_url "GitHub 프로필 URL"
VARCHAR avatar_url "GitHub 아바타 이미지 URL"
TIMESTAMP created_at "생성 시간"
TIMESTAMP updated_at "갱신 시간"
BOOLEAN is_member_of_org "조직 소속 여부"
VARCHAR access_token "내부 애플리케이션 액세스 토큰"
TIMESTAMP token_expiration "토큰 만료 시간"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package entry.dsm.gitauth.equusgithubauth

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.cloud.openfeign.EnableFeignClients

@EnableFeignClients
@SpringBootApplication
class EquusGithubAuthApplication

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.presentation.controller

import entry.dsm.gitauth.equusgithubauth.domain.user.entity.User
import entry.dsm.gitauth.equusgithubauth.domain.user.service.UserInformationService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/github/auth")
class GithubAuthenticationController(
val userInformationService: UserInformationService
) {

@GetMapping
fun githubAuth(): String {
return "redirect:/oauth2/authorization/github"
}

// 이 주석 아래 메서드들은 테스트용 메서드 입니다.
@GetMapping("/authenticated/")
fun getGithubUserInfo(@AuthenticationPrincipal oAuth2User: OAuth2User): User {
return userInformationService.execute(oAuth2User)
}

@GetMapping("/not/authenticated/")
fun notAuthenticated(): String {
return "Not authenticated"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.presentation.dto

import java.time.LocalDateTime

data class GithubUserInformation(
val githubId: String,
val username: String,
val email: String?,
val profileUrl: String,
val avatarUrl: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val accessToken: String,
val tokenExpiration: LocalDateTime
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.service

import entry.dsm.gitauth.equusgithubauth.domain.auth.presentation.dto.GithubUserInformation
import entry.dsm.gitauth.equusgithubauth.domain.user.entity.repository.UserRepository
import org.slf4j.LoggerFactory
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter

@Service
class GithubUserService(
private val authorizedClientService: OAuth2AuthorizedClientService,
private val githubUserValidationService: GithubUserValidationService,
private val githubUserTokenValidationService: GithubUserTokenValidationService,
) {
private val logger = LoggerFactory.getLogger(GithubUserService::class.java)
private val timestampFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME

fun getGithubUserInformation(oAuth2User: OAuth2User): GithubUserInformation {
return try {
val client = getAuthorizedClient(oAuth2User)

githubUserTokenValidationService.validateAccessToken(client.accessToken.tokenValue)

validateOrganizationMembership(client, oAuth2User)

return createGithubUserInformation(oAuth2User, client)
} catch (e: Exception) {
logger.error("GitHub 사용자 정보 취득 중 오류 발생: ${e.message}", e)
throw IllegalStateException("GitHub 사용자 정보를 가져올 수 없습니다.", e)
}
}

private fun getAuthorizedClient(oAuth2User: OAuth2User): OAuth2AuthorizedClient {
return authorizedClientService.loadAuthorizedClient("github", oAuth2User.name)
?: throw IllegalArgumentException("사용자 ${oAuth2User.name}에 대한 인증된 클라이언트를 찾을 수 없습니다.")
}

private fun validateOrganizationMembership(client: OAuth2AuthorizedClient, oAuth2User: OAuth2User) {
val username = oAuth2User.attributes["login"].toString()
if (!githubUserValidationService.validateUserMembership(client.accessToken.tokenValue, username)) {
logger.warn("조직 멤버십 검증 실패: $username")
throw IllegalArgumentException("사용자가 조직의 멤버가 아닙니다.")
}
logger.info("조직 멤버십 검증 성공: $username")
}

private fun createGithubUserInformation(
oAuth2User: OAuth2User,
client: OAuth2AuthorizedClient
): GithubUserInformation {
return GithubUserInformation(
githubId = getRequiredAttributeValue(oAuth2User, "id"),
username = getRequiredAttributeValue(oAuth2User, "login"),
email = getOptionalAttributeValue(oAuth2User, "email"),
profileUrl = getRequiredAttributeValue(oAuth2User, "html_url"),
avatarUrl = getRequiredAttributeValue(oAuth2User, "avatar_url"),
createdAt = parseTimestamp(getRequiredAttributeValue(oAuth2User, "created_at")),
updatedAt = parseTimestamp(getRequiredAttributeValue(oAuth2User, "updated_at")),
accessToken = client.accessToken.tokenValue,
tokenExpiration = parseTokenExpirationTime(client)
)
}

private fun parseTimestamp(timestamp: String): LocalDateTime {
return LocalDateTime.parse(
timestamp.replace("Z", ""),
timestampFormatter
)
}

private fun parseTokenExpirationTime(client: OAuth2AuthorizedClient): LocalDateTime {
return client.accessToken.expiresAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
?: throw IllegalStateException("토큰 만료 시간이 누락되었습니다.")
}

private fun getRequiredAttributeValue(oAuth2User: OAuth2User, attributeName: String): String {
return oAuth2User.attributes[attributeName]?.toString()
?: throw IllegalStateException("사용자 ${oAuth2User.attributes["login"]}의 필수 속성 '$attributeName'이(가) 누락되었습니다.")
}

private fun getOptionalAttributeValue(oAuth2User: OAuth2User, attributeName: String): String? {
return oAuth2User.attributes[attributeName]?.toString()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.service

import entry.dsm.gitauth.equusgithubauth.domain.auth.client.GithubApiClient
import entry.dsm.gitauth.equusgithubauth.global.external.service.TokenAuthenticator
import org.springframework.stereotype.Service

@Service
class GithubUserTokenValidationService(
private val githubClient: GithubApiClient,
private val tokenAuthenticator: TokenAuthenticator
) {
fun validateAccessToken(token: String) {
require(token.isNotBlank()) { "Access token is empty." }
try {
githubClient.getUser(tokenAuthenticator.createAuthorizationHeader(token))
} catch (ex: Exception) {
throw IllegalArgumentException("Access token is expired or invalid.")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.service

import entry.dsm.gitauth.equusgithubauth.domain.auth.client.GithubApiClient
import entry.dsm.gitauth.equusgithubauth.global.external.github.presentation.dto.GithubOrganizationResponse
import entry.dsm.gitauth.equusgithubauth.global.external.service.TokenAuthenticator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class GithubUserValidationService(
private val githubApiClient: GithubApiClient,
private val tokenAuthenticator: TokenAuthenticator
) {
private val logger = LoggerFactory.getLogger(GithubUserValidationService::class.java)

companion object {
private const val TARGET_ORGANIZATION = "EntryDSM"
}

fun validateUserMembership(token: String, username: String): Boolean {
return try {
val authorizationHeader = tokenAuthenticator.createAuthorizationHeader(token)

val currentUsername = githubApiClient.getUser(authorizationHeader).login
if (currentUsername != username) {
logger.error("Token username mismatch: $currentUsername != $username")
return false
}

val isMemberOfOrg = githubApiClient.getUserOrganizations(authorizationHeader, username)
.any { org: GithubOrganizationResponse -> org.login == TARGET_ORGANIZATION }

logger.info("Membership status for $username in $TARGET_ORGANIZATION: $isMemberOfOrg")
isMemberOfOrg
} catch (e: Exception) {
logger.error("Error validating GitHub user membership for $username", e)
false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package entry.dsm.gitauth.equusgithubauth.domain.user.entity

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.*

@Entity
@Table(name = "users")
data class User(
@Id
@Column(name = "id", nullable = false)
val id: UUID = UUID.randomUUID(),

@Column(name = "github_id", nullable = false, unique = true)
val githubId: String,

@Column(name = "username", nullable = false)
val username: String,

@Column(name = "email", nullable = true, unique = true)
val email: String?,

@Column(name = "profile_url")
val profileUrl: String? = null,

@Column(name = "avatar_url")
val avatarUrl: String? = null,

@Column(name = "created_at", nullable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),

@Column(name = "updated_at")
var updatedAt: LocalDateTime = LocalDateTime.now(),

@Column(name = "is_member_of_org", nullable = false)
val isMemberOfOrg: Boolean = false,

@Column(name = "access_token")
var accessToken: String? = null,

@Column(name = "token_expiration")
var tokenExpiration: LocalDateTime? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package entry.dsm.gitauth.equusgithubauth.domain.user.entity.repository

import org.springframework.data.jpa.repository.JpaRepository
import entry.dsm.gitauth.equusgithubauth.domain.user.entity.User
import java.util.*

interface UserRepository : JpaRepository<User, UUID> {
fun findByGithubId(githubId: String): User?
}
Loading

0 comments on commit 44b5d70

Please sign in to comment.