Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,18 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.github.f4b6a3:tsid-creator:5.2.6")

runtimeOnly("org.mariadb.jdbc:mariadb-java-client")

implementation("io.jsonwebtoken:jjwt-api:0.12.6")
implementation("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.doongjun.commitmon.api
package com.doongjun.commitmon.animation

import com.doongjun.commitmon.app.AdventureFacade
import com.doongjun.commitmon.app.Theme
Expand Down
63 changes: 63 additions & 0 deletions src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.doongjun.commitmon.api

import com.doongjun.commitmon.api.data.RedirectDestination
import com.doongjun.commitmon.api.data.RefreshTokenRequest
import com.doongjun.commitmon.api.data.RefreshTokenResponse
import com.doongjun.commitmon.app.AccountFacade
import com.doongjun.commitmon.app.GithubOAuth2Service
import io.swagger.v3.oas.annotations.Operation
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/account")
class AccountController(
private val accountFacade: AccountFacade,
private val githubOAuth2Service: GithubOAuth2Service,
) {
@Operation(summary = "로그인")
@GetMapping("/login")
fun login(
@RequestHeader("Redirect-Destination", defaultValue = "LOCAL") destination: RedirectDestination,
): ResponseEntity<Unit> =
ResponseEntity
.status(HttpStatus.MOVED_PERMANENTLY)
.header(
HttpHeaders.LOCATION,
githubOAuth2Service.getRedirectUrl(destination),
).build()

@Operation(summary = "로그인 Callback (시스템에서 호출)")
@GetMapping("/oauth/github/callback/{destination}")
fun loginCallback(
@PathVariable destination: RedirectDestination,
@RequestParam code: String,
): ResponseEntity<Unit> {
val (accessToken, refreshToken) = accountFacade.login(code)
return ResponseEntity
.status(HttpStatus.TEMPORARY_REDIRECT)
.header(
HttpHeaders.LOCATION,
destination.getClientUrl(accessToken, refreshToken),
).build()
}

@Operation(summary = "토큰 갱신")
@PostMapping("/refresh")
fun refresh(
@Valid @RequestBody request: RefreshTokenRequest,
): ResponseEntity<RefreshTokenResponse> {
val (accessToken, refreshToken) = accountFacade.refresh(token = request.refreshToken!!)
return ResponseEntity.ok(RefreshTokenResponse.of(accessToken, refreshToken))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.doongjun.commitmon.api.data

enum class RedirectDestination(
val callbackUrl: String,
private val clientUrl: String,
) {
PRODUCTION(
"https://commitmon.me/api/v1/account/oauth/github/callback/PRODUCTION",
"https://commitmon-client.vercel.app/account",
),
LOCAL(
"https://commitmon.me/api/v1/account/oauth/github/callback/LOCAL",
"http://localhost:3000/account",
),
;

fun getClientUrl(
accessToken: String,
refreshToken: String,
): String = "$clientUrl?accessToken=$accessToken&refreshToken=$refreshToken"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.doongjun.commitmon.api.data

import jakarta.validation.constraints.NotNull

data class RefreshTokenRequest(
@field:NotNull
val refreshToken: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.doongjun.commitmon.api.data

data class RefreshTokenResponse(
val accessToken: String,
val refreshToken: String,
) {
companion object {
fun of(
accessToken: String,
refreshToken: String,
): RefreshTokenResponse = RefreshTokenResponse(accessToken, refreshToken)
}
}
66 changes: 66 additions & 0 deletions src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.doongjun.commitmon.app

import com.doongjun.commitmon.app.data.AuthDto
import com.doongjun.commitmon.app.data.CreateUserDto
import com.doongjun.commitmon.app.data.GetUserDto
import com.doongjun.commitmon.config.security.TokenProvider
import org.springframework.security.authentication.AccountExpiredException
import org.springframework.stereotype.Component

@Component
class AccountFacade(
private val userService: UserService,
private val githubService: GithubService,
private val githubOAuth2Service: GithubOAuth2Service,
private val tokenProvider: TokenProvider,
) {
fun login(code: String): AuthDto {
val userLogin = githubOAuth2Service.getUserLogin(code)
val user = getOrCreateUser(userLogin)

return AuthDto(
accessToken = tokenProvider.createAccessToken(user.id),
refreshToken = tokenProvider.createRefreshToken(user.id),
)
}

private fun getOrCreateUser(username: String): GetUserDto =
runCatching {
userService.getByName(
name = username,
userFetchType = UserFetchType.SOLO,
)
}.getOrElse {
val (totalCommitCount) = githubService.getUserCommitInfo(username)
val (followerNames, followingNames) = githubService.getUserFollowInfo(username, 100)
val userId =
CreateUserDto(
name = username,
totalCommitCount = totalCommitCount,
followerNames = followerNames,
followingNames = followingNames,
).let { dto ->
userService.create(dto)
}
userService.get(userId, UserFetchType.SOLO)
}

fun refresh(token: String): AuthDto {
val refreshToken =
tokenProvider.getRefreshToken(token)
?: throw AccountExpiredException("Refresh token is expired")

return AuthDto(
accessToken = tokenProvider.createAccessToken(refreshToken.userId!!),
refreshToken =
when (refreshToken.isLessThanWeek()) {
true -> {
val user = userService.get(refreshToken.userId, UserFetchType.SOLO)
tokenProvider.expireRefreshToken(token)
tokenProvider.createRefreshToken(user.id)
}
false -> refreshToken.token!!
},
)
}
}
34 changes: 34 additions & 0 deletions src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.doongjun.commitmon.app

import com.doongjun.commitmon.api.data.RedirectDestination
import com.doongjun.commitmon.infra.GithubOAuth2Api
import com.doongjun.commitmon.infra.GithubRestApi
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service

@Service
class GithubOAuth2Service(
private val githubOAuth2Api: GithubOAuth2Api,
private val githubRestApi: GithubRestApi,
@Value("\${app.github.oauth2.base-url}")
private val githubOAuth2BaseUrl: String,
@Value("\${app.github.oauth2.client-id}")
private val githubClientId: String,
@Value("\${app.github.oauth2.client-secret}")
private val githubClientSecret: String,
) {
fun getRedirectUrl(destination: RedirectDestination): String =
"$githubOAuth2BaseUrl/authorize?client_id=$githubClientId&redirect_uri=${destination.callbackUrl}"

fun getUserLogin(code: String): String {
val userToken =
githubOAuth2Api
.fetchAccessToken(
code = code,
clientId = githubClientId,
clientSecret = githubClientSecret,
).accessToken

return githubRestApi.fetchUserInfo(userToken)
}
}
6 changes: 6 additions & 0 deletions src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.doongjun.commitmon.app.data

data class AuthDto(
val accessToken: String,
val refreshToken: String,
)
23 changes: 21 additions & 2 deletions src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
package com.doongjun.commitmon.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor

@Configuration
@EnableAsync
class AsyncConfig
@Configuration
class AsyncConfig {
@Bean
fun taskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor()
executor.setThreadNamePrefix("async-executor-")
executor.corePoolSize = POOL_SIZE
executor.maxPoolSize = POOL_SIZE * 2
executor.queueCapacity = QUEUE_SIZE
executor.setWaitForTasksToCompleteOnShutdown(true)
executor.initialize()
return executor
}

companion object {
private const val POOL_SIZE = 3
private const val QUEUE_SIZE = 3
}
}
36 changes: 36 additions & 0 deletions src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.doongjun.commitmon.config

import io.swagger.v3.oas.annotations.OpenAPIDefinition
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.info.Info
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springdoc.core.models.GroupedOpenApi
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@OpenAPIDefinition(
info =
Info(
title = "Commitmon API",
description = "API Documents",
version = "v1.0.0",
),
security = [SecurityRequirement(name = "Bearer Authentication")],
)
@SecurityScheme(
name = "Bearer Authentication",
type = SecuritySchemeType.HTTP,
bearerFormat = "JWT",
scheme = "bearer",
)
@Configuration
class SwaggerConfig {
@Bean
fun api(): GroupedOpenApi =
GroupedOpenApi
.builder()
.group("api-v1-definition")
.pathsToMatch("/api/**")
.build()
}
14 changes: 12 additions & 2 deletions src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ class WebClientConfig {

@Bean(name = ["githubRestWebClient"])
fun githubRestWebClient(
@Value("\${app.github.token}") token: String,
@Value("\${app.github.base-url}") baseUrl: String,
): WebClient =
WebClient
Expand All @@ -34,6 +33,17 @@ class WebClientConfig {
.baseUrl(baseUrl)
.defaultHeaders { httpHeaders ->
httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer $token")
}.build()

@Bean(name = ["githubOAuth2WebClient"])
fun githubOAuth2WebClient(
@Value("\${app.github.oauth2.base-url}") baseUrl: String,
): WebClient =
WebClient
.builder()
.uriBuilderFactory(DefaultUriBuilderFactory(baseUrl))
.baseUrl(baseUrl)
.defaultHeaders { httpHeaders ->
httpHeaders.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
}.build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.doongjun.commitmon.config.security

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
import org.springframework.web.filter.OncePerRequestFilter

class JwtFilter(
private val tokenProvider: TokenProvider,
) : OncePerRequestFilter() {
private val repository = RequestAttributeSecurityContextRepository()

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val token = resolveToken(request)

if (!token.isNullOrEmpty() && tokenProvider.validateAccessToken(token)) {
val userId = tokenProvider.extractUserId(token)

val authenticationToken =
UsernamePasswordAuthenticationToken(
userId,
null,
)

authenticationToken.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authenticationToken
repository.saveContext(SecurityContextHolder.getContext(), request, response)
}

filterChain.doFilter(request, response)
}

private fun resolveToken(request: HttpServletRequest): String? {
val authorization = request.getHeader(HttpHeaders.AUTHORIZATION)
if (!authorization.isNullOrEmpty() && authorization.startsWith("Bearer ")) {
return authorization.substring(7)
}

return null
}
}
Loading