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

Feature/2 develop GitHub oauth #4

Merged
merged 21 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8a81664
chore : (#2) Update DB scheme
kangeunchan Nov 5, 2024
37c2060
feat : (#2) Update dependencies
kangeunchan Nov 5, 2024
4abc38a
feat : (#2) set application properties
kangeunchan Nov 5, 2024
d0879b1
feat : (#2) Add lombok dependency
kangeunchan Nov 5, 2024
2defb23
fix : (#3) Switch build file from Groovy to Kotlin script
kangeunchan Nov 5, 2024
f49805e
feat : (#2) Add User Entity
kangeunchan Nov 5, 2024
3c56361
feat : (#2) update database scheme(remove is_member_of_org colum)
kangeunchan Nov 6, 2024
5598ff0
feat : (#2) Repair existing code
kangeunchan Dec 7, 2024
a58e666
feat : Add github login endpoint
kangeunchan Dec 7, 2024
e18ca71
feat : (#2) Repair existing code
kangeunchan Dec 7, 2024
bd50db7
style : (#2) Delete unnecessary import statements
kangeunchan Dec 7, 2024
af4778e
feat : (#2) Refactor GitHub authentication to use User entity and ser…
kangeunchan Dec 8, 2024
b6474d0
refactor: (#2) improve package structure
kangeunchan Dec 8, 2024
94f2b7f
chore : (#2) Update Readme
kangeunchan Dec 19, 2024
d0dd478
feat :: (#2) springboot version up for spring cloud openfeign
kangeunchan Dec 19, 2024
e33758c
feat : (#2) Refactoring using spring cloud openfeign
kangeunchan Dec 19, 2024
10961a3
fix : (#2) Change primary key type in UserRepository from Long to UUI…
kangeunchan Dec 19, 2024
e900a21
feat : (#2) Changed to use github api client
kangeunchan Dec 19, 2024
3ac718b
feat : (#2) Remove builder annotation in user data class
kangeunchan Dec 20, 2024
2c3e923
fix : (#2) Unresolved reference: UUID
kangeunchan Dec 20, 2024
fe5fa8e
feat : (#2) Change GithubUserTokenValidationService use TokenAuthenti…
kangeunchan Dec 20, 2024
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
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,57 @@
package entry.dsm.gitauth.equusgithubauth.domain.auth.service

import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.RequestEntity
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate

@Service
class GithubUserTokenValidationService(
private val restTemplate: RestTemplate
kangeunchan marked this conversation as resolved.
Show resolved Hide resolved
) {
private val logger = LoggerFactory.getLogger(GithubUserTokenValidationService::class.java)

fun validateAccessToken(token: String) {
token.takeIf { it.isNotBlank() } ?: throw IllegalArgumentException("Access token is empty.")
val isTokenValid = retry(3, 2000) { isTokenActive(token) } // Add delay between retries
if (!isTokenValid!!) {
throw IllegalArgumentException("Access token is expired or invalid.")
}
}

private fun retry(times: Int, delay: Long = 0L, block: () -> Boolean?): Boolean? {
repeat(times - 1) {
val result = try {
block()
} catch (e: Exception) {
logger.error("Retry failed: ${e.message}")
null
}
if (result == true) return result
Thread.sleep(delay)
}
return block()
}
kangeunchan marked this conversation as resolved.
Show resolved Hide resolved

private fun isTokenActive(token: String): Boolean {
return try {
val request = buildGithubApiRequest(token)
val response: ResponseEntity<String> = restTemplate.exchange(request, String::class.java)
response.statusCode == HttpStatus.OK
} catch (ex: Exception) {
logger.error("Error occurred while validating GitHub access token: ${ex.message}", ex)
false
}
}

private fun buildGithubApiRequest(token: String): RequestEntity<Void> {
val url = "https://api.github.com/user"
val headers = HttpHeaders().apply {
set("Authorization", "Bearer $token")
}
return RequestEntity.get(url).headers(headers).build()
}
}
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
}
}
}
Loading