From dd4df668397847a31f6be30c99ad98c57e024aa4 Mon Sep 17 00:00:00 2001 From: doongjun Date: Fri, 15 Nov 2024 18:51:43 +0900 Subject: [PATCH 1/8] =?UTF-8?q?#50=20feat:=20=EC=9C=A0=EC=A0=80=20OAuth2?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commitmon/api/AccountController.kt | 38 +++++++++++++++++++ .../commitmon/api/data/RedirectDestination.kt | 8 ++++ .../doongjun/commitmon/app/AccountFacade.kt | 13 +++++++ .../commitmon/app/GithubOAuth2Service.kt | 34 +++++++++++++++++ .../commitmon/config/WebClientConfig.kt | 14 ++++++- .../commitmon/infra/GithubOAuth2Api.kt | 29 ++++++++++++++ .../doongjun/commitmon/infra/GithubRestApi.kt | 21 ++++++++++ .../infra/data/OAuthLoginResponse.kt | 8 ++++ .../commitmon/infra/data/UserInfoResponse.kt | 5 +++ src/main/resources/application.yml | 6 ++- 10 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt new file mode 100644 index 0000000..9929305 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -0,0 +1,38 @@ +package com.doongjun.commitmon.api + +import com.doongjun.commitmon.api.data.RedirectDestination +import com.doongjun.commitmon.app.AccountFacade +import com.doongjun.commitmon.app.GithubOAuth2Service +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.RequestHeader +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class AccountController( + private val accountFacade: AccountFacade, + private val githubOAuth2Service: GithubOAuth2Service, +) { + @GetMapping("/login") + fun login( + @RequestHeader("Redirect-Destination", defaultValue = "LOCAL") destination: RedirectDestination, + ): ResponseEntity = + ResponseEntity + .status(HttpStatus.MOVED_PERMANENTLY) + .header( + HttpHeaders.LOCATION, + githubOAuth2Service.getRedirectUrl(destination), + ).build() + + @GetMapping("/oauth/github/callback/{destination}") + fun loginCallback( + @PathVariable destination: RedirectDestination, + @RequestParam code: String, + ) { + accountFacade.authenticate(code) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt new file mode 100644 index 0000000..85a5619 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt @@ -0,0 +1,8 @@ +package com.doongjun.commitmon.api.data + +enum class RedirectDestination( + val callbackUrl: String, +) { + PRODUCTION("https://commitmon.me/oauth/github/callback/PRODUCTION"), + LOCAL("http://localhost:8080/oauth/github/callback/LOCAL"), +} diff --git a/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt new file mode 100644 index 0000000..a9679a4 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt @@ -0,0 +1,13 @@ +package com.doongjun.commitmon.app + +import org.springframework.stereotype.Component + +@Component +class AccountFacade( + private val userService: UserService, + private val githubOAuth2Service: GithubOAuth2Service, +) { + fun authenticate(code: String) { + val userLogin = githubOAuth2Service.getUserLogin(code) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt b/src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt new file mode 100644 index 0000000..2c807c6 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt b/src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt index 2efd756..0c7b1c1 100644 --- a/src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt +++ b/src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt @@ -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 @@ -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() } diff --git a/src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt b/src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt new file mode 100644 index 0000000..b3439b6 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt @@ -0,0 +1,29 @@ +package com.doongjun.commitmon.infra + +import com.doongjun.commitmon.infra.data.OAuthLoginResponse +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +@Component +class GithubOAuth2Api( + private val githubOAuth2WebClient: WebClient, +) { + fun fetchAccessToken( + code: String, + clientId: String, + clientSecret: String, + ): OAuthLoginResponse = + githubOAuth2WebClient + .post() + .uri { uriBuilder -> + uriBuilder + .path("/access_token") + .queryParam("client_id", clientId) + .queryParam("client_secret", clientSecret) + .queryParam("code", code) + .build() + }.retrieve() + .bodyToMono(OAuthLoginResponse::class.java) + .onErrorMap { error -> throw IllegalArgumentException("Failed to fetch access token: $error") } + .block()!! +} diff --git a/src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt b/src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt index f2a44ef..9d4e688 100644 --- a/src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt +++ b/src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt @@ -1,13 +1,32 @@ package com.doongjun.commitmon.infra import com.doongjun.commitmon.infra.data.UserCommitSearchResponse +import com.doongjun.commitmon.infra.data.UserInfoResponse +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient @Component class GithubRestApi( private val githubRestWebClient: WebClient, + @Value("\${app.github.token}") + private val githubToken: String, ) { + fun fetchUserInfo(userToken: String): String = + githubRestWebClient + .get() + .uri { uriBuilder -> + uriBuilder + .path("/user") + .build() + }.headers { headers -> + headers.add("Authorization", "Bearer $userToken") + }.retrieve() + .bodyToMono(UserInfoResponse::class.java) + .onErrorMap { error -> throw IllegalArgumentException("Failed to fetch user: $error") } + .block()!! + .login + fun fetchUserCommitSearchInfo(username: String): UserCommitSearchResponse = githubRestWebClient .get() @@ -17,6 +36,8 @@ class GithubRestApi( .queryParam("q", "author:$username") .queryParam("per_page", 1) .build() + }.headers { headers -> + headers.add("Authorization", "Bearer $githubToken") }.retrieve() .bodyToMono(UserCommitSearchResponse::class.java) .onErrorMap { error -> throw IllegalArgumentException("Failed to fetch user commit count: $error") } diff --git a/src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt b/src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt new file mode 100644 index 0000000..8b0dce7 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt @@ -0,0 +1,8 @@ +package com.doongjun.commitmon.infra.data + +import com.fasterxml.jackson.annotation.JsonProperty + +data class OAuthLoginResponse( + @JsonProperty("access_token") + val accessToken: String, +) diff --git a/src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt b/src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt new file mode 100644 index 0000000..5ba1377 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt @@ -0,0 +1,5 @@ +package com.doongjun.commitmon.infra.data + +data class UserInfoResponse( + val login: String, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6d3b03d..b033972 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,4 +23,8 @@ server: app: github: token: ${GITHUB_TOKEN} - base-url: https://api.github.com \ No newline at end of file + base-url: https://api.github.com + oauth2: + base-url: https://github.com/login/oauth + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} \ No newline at end of file From 1d36e6f42d7f36f43f2e2836a775d33d2ab944f8 Mon Sep 17 00:00:00 2001 From: doongjun Date: Sun, 17 Nov 2024 11:38:59 +0900 Subject: [PATCH 2/8] =?UTF-8?q?#50=20feat:=20Error=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/error/GlobalExceptionHandler.kt | 78 +++++++++++++++++++ .../core/error/response/ErrorCode.kt | 14 ++++ .../core/error/response/ErrorResponse.kt | 58 ++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..eaa9c92 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt @@ -0,0 +1,78 @@ +package com.doongjun.commitmon.core.error + +import com.doongjun.commitmon.core.error.response.ErrorCode +import com.doongjun.commitmon.core.error.response.ErrorResponse +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.access.AccessDeniedException +import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice(annotations = [RestController::class]) +class GlobalExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(Exception::class) + protected fun handleException(e: Exception): ResponseEntity { + log.error("Exception", e) + val response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response) + } + + @ExceptionHandler(BindException::class) + protected fun handleBindException(e: BindException): ResponseEntity { + log.error("BindException", e) + val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } + + @ExceptionHandler(MissingServletRequestParameterException::class) + protected fun handleMissingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity { + log.error("MethodArgumentTypeMismatchException", e) + val response = ErrorResponse.of(e) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + protected fun handleMethodArgumentTypeMismatchException(e: MethodArgumentTypeMismatchException): ResponseEntity { + log.error("MethodArgumentTypeMismatchException", e) + val response = ErrorResponse.of(e) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + protected fun handleHttpRequestMethodNotSupportedException(e: HttpRequestMethodNotSupportedException): ResponseEntity { + log.error("HttpRequestMethodNotSupportedException", e) + val response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED) + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(response) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + protected fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity { + log.error("HttpMessageNotReadableException", e) + val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + protected fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity { + log.error("MethodArgumentNotValidException", e) + val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } + + @ExceptionHandler(AccessDeniedException::class) + protected fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity { + log.error("AccessDeniedException", e) + val response = ErrorResponse.of(ErrorCode.ACCESS_DENIED) + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt new file mode 100644 index 0000000..2fcccbb --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt @@ -0,0 +1,14 @@ +package com.doongjun.commitmon.core.error.response + +enum class ErrorCode( + val status: Int, + val code: String, + val message: String, +) { + INVALID_INPUT_VALUE(400, "A001", "Invalid input value."), + METHOD_NOT_ALLOWED(405, "A002", "Invalid input value."), + INTERNAL_SERVER_ERROR(500, "A003", "Server Error."), + INVALID_TYPE_VALUE(400, "A005", "Invalid type value."), + ACCESS_DENIED(403, "A006", "Access is denied."), + UNAUTHORIZED(401, "A007", "Unauthorized."), +} diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt new file mode 100644 index 0000000..fe39e04 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt @@ -0,0 +1,58 @@ +package com.doongjun.commitmon.core.error.response + +import org.springframework.validation.BindingResult +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +data class ErrorResponse private constructor( + val message: String, + val status: Int, + val code: String, + val errors: List, +) { + companion object { + fun of(errorCode: ErrorCode) = ErrorResponse(errorCode) + + fun of( + errorCode: ErrorCode, + bindingResult: BindingResult, + ) = ErrorResponse(errorCode, FieldError.of(bindingResult)) + + fun of( + errorCode: ErrorCode, + errors: List, + ) = ErrorResponse(errorCode, errors) + + fun of(e: MethodArgumentTypeMismatchException) = + ErrorResponse(ErrorCode.INVALID_TYPE_VALUE, FieldError.of(e.name, e.value.toString(), e.errorCode)) + + fun of(e: MissingServletRequestParameterException) = + ErrorResponse(ErrorCode.INVALID_INPUT_VALUE, FieldError.of(e.parameterName, null, e.message)) + } + + private constructor(errorCode: ErrorCode, errors: List = emptyList()) : this( + message = errorCode.message, + status = errorCode.status, + code = errorCode.code, + errors = errors, + ) +} + +data class FieldError private constructor( + val field: String? = "", + val value: String? = "", + val reason: String? = "", +) { + companion object { + fun of( + field: String?, + value: String?, + reason: String?, + ) = listOf(FieldError(field, value, reason)) + + fun of(bindingResult: BindingResult) = + bindingResult.fieldErrors.map { error -> + FieldError(error.field, error.rejectedValue?.toString(), error.defaultMessage) + } + } +} From cac48936429ab9fbc9c71c60bee671cb5ab1bea0 Mon Sep 17 00:00:00 2001 From: doongjun Date: Sun, 17 Nov 2024 12:16:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?#50=20feat:=20jwt=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 5 ++ .../commitmon/api/AccountController.kt | 13 +++- .../commitmon/api/data/RedirectDestination.kt | 11 +++- .../doongjun/commitmon/app/AccountFacade.kt | 31 ++++++++- .../commitmon/config/security/JwtFilter.kt | 50 +++++++++++++++ .../security/RestAccessDeniedHandler.kt | 22 +++++++ .../security/RestAuthenticationEntryPoint.kt | 22 +++++++ .../config/security/SecurityConfig.kt | 37 +++++++++++ .../config/security/TokenProvider.kt | 64 +++++++++++++++++++ .../core/error/GlobalExceptionHandler.kt | 7 ++ .../commitmon/extension/StringExtension.kt | 13 ++++ src/main/resources/application.yml | 6 ++ 12 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt diff --git a/build.gradle.kts b/build.gradle.kts index 90fa6b2..c1b4422 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,10 +43,15 @@ 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("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") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt index 9929305..e83e64e 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -9,10 +9,12 @@ 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.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, @@ -32,7 +34,14 @@ class AccountController( fun loginCallback( @PathVariable destination: RedirectDestination, @RequestParam code: String, - ) { - accountFacade.authenticate(code) + ): ResponseEntity { + val accessToken = accountFacade.login(code) + + return ResponseEntity + .status(HttpStatus.TEMPORARY_REDIRECT) + .header( + HttpHeaders.LOCATION, + "${destination.clientUrl}?accessToken=$accessToken", + ).build() } } diff --git a/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt index 85a5619..5c15aa8 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt @@ -2,7 +2,14 @@ package com.doongjun.commitmon.api.data enum class RedirectDestination( val callbackUrl: String, + val clientUrl: String, ) { - PRODUCTION("https://commitmon.me/oauth/github/callback/PRODUCTION"), - LOCAL("http://localhost:8080/oauth/github/callback/LOCAL"), + PRODUCTION( + "https://commitmon.me/api/v1/account/oauth/github/callback/PRODUCTION", + "https://commitmon-client.vercel.app", + ), + LOCAL( + "http://localhost:8080/api/v1/account/oauth/github/callback/LOCAL", + "http://localhost:3000", + ), } diff --git a/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt index a9679a4..15591b3 100644 --- a/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt +++ b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt @@ -1,13 +1,42 @@ package com.doongjun.commitmon.app +import com.doongjun.commitmon.app.data.CreateUserDto +import com.doongjun.commitmon.app.data.GetUserDto +import com.doongjun.commitmon.config.security.TokenProvider 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 authenticate(code: String) { + fun login(code: String): String { val userLogin = githubOAuth2Service.getUserLogin(code) + val user = getOrCreateUser(userLogin) + + return tokenProvider.createAccessToken(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) + } } diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt new file mode 100644 index 0000000..7c8d13a --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt new file mode 100644 index 0000000..2b79761 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt @@ -0,0 +1,22 @@ +package com.doongjun.commitmon.config.security + +import com.doongjun.commitmon.core.error.response.ErrorCode +import com.doongjun.commitmon.core.error.response.ErrorResponse +import com.doongjun.commitmon.extension.convertToString +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler + +class RestAccessDeniedHandler : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest?, + response: HttpServletResponse, + e: AccessDeniedException, + ) { + val errorResponse = ErrorResponse.of(ErrorCode.ACCESS_DENIED) + response.contentType = "application/json" + response.status = HttpServletResponse.SC_FORBIDDEN + response.writer?.write(errorResponse.convertToString()) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt new file mode 100644 index 0000000..669f771 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt @@ -0,0 +1,22 @@ +package com.doongjun.commitmon.config.security + +import com.doongjun.commitmon.core.error.response.ErrorCode +import com.doongjun.commitmon.core.error.response.ErrorResponse +import com.doongjun.commitmon.extension.convertToString +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint + +class RestAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + e: AuthenticationException, + ) { + val errorResponse = ErrorResponse.of(ErrorCode.UNAUTHORIZED) + response.contentType = "application/json" + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer?.write(errorResponse.convertToString()) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt new file mode 100644 index 0000000..0895ad7 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt @@ -0,0 +1,37 @@ +package com.doongjun.commitmon.config.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val tokenProvider: TokenProvider, +) { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .formLogin { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .addFilterBefore(JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter::class.java) + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers("/adventure").permitAll() + .requestMatchers("/api/v1/account/**").permitAll() + .anyRequest().authenticated() + } + .exceptionHandling { exception -> + exception + .accessDeniedHandler(RestAccessDeniedHandler()) + .authenticationEntryPoint(RestAuthenticationEntryPoint()) + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt new file mode 100644 index 0000000..fe32373 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt @@ -0,0 +1,64 @@ +package com.doongjun.commitmon.config.security + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Date + +@Component +class TokenProvider( + @Value("\${app.auth.jwt.base64-secret}") + private val base64Secret: String, + @Value("\${app.auth.jwt.expired-ms}") + private val expiredMs: Long, +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createAccessToken(userId: Long): String { + val now = Date() + val expiration = Date(now.time + expiredMs) + + return Jwts.builder() + .claims(mapOf("sub" to userId.toString())) + .issuedAt(now) + .expiration(expiration) + .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) + .compact() + } + + fun extractUserId(accessToken: String): Long { + val claims = + Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) + .build() + .parseSignedClaims(accessToken) + .payload + return claims["sub"].toString().toLong() + } + + fun validateAccessToken(accessToken: String): Boolean { + try { + Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) + .build() + .parseSignedClaims(accessToken) + + return true + } catch (ex: MalformedJwtException) { + log.error("Invalid JWT token") + } catch (ex: ExpiredJwtException) { + log.error("Expired JWT token") + } catch (ex: UnsupportedJwtException) { + log.error("Unsupported JWT token") + } catch (ex: IllegalArgumentException) { + log.error("JWT claims string is empty.") + } + return false + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt index eaa9c92..c7a778e 100644 --- a/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt @@ -75,4 +75,11 @@ class GlobalExceptionHandler { val response = ErrorResponse.of(ErrorCode.ACCESS_DENIED) return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response) } + + @ExceptionHandler(IllegalArgumentException::class) + protected fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity { + log.error("IllegalArgumentException", e) + val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE) + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response) + } } diff --git a/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt b/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt new file mode 100644 index 0000000..c7e9401 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt @@ -0,0 +1,13 @@ +package com.doongjun.commitmon.extension + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +fun Any.convertToString(): String { + return jacksonObjectMapper().writeValueAsString(this) +} + +fun String.convertToObject(): T { + val typeReference = object : TypeReference() {} + return jacksonObjectMapper().readValue(this, typeReference) +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b033972..322da19 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,12 @@ server: relaxed-query-chars: "{,}" app: + auth: + jwt: + base64-secret: ${JWT_SECRET} + expired-ms: 3600000 # 1hour + refresh-token: + expired-ms: 1209600000 # 2weeks github: token: ${GITHUB_TOKEN} base-url: https://api.github.com From 6fca1f7c28b8b26292090cac9a92e0c65f90f8d8 Mon Sep 17 00:00:00 2001 From: doongjun Date: Mon, 18 Nov 2024 11:29:15 +0900 Subject: [PATCH 4/8] =?UTF-8?q?#50=20fix:=20Exception=20Handler=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commitmon/{api => animation}/AdventureController.kt | 2 +- src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt | 1 - .../doongjun/commitmon/core/error/AnimationExceptionHandler.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) rename src/main/kotlin/com/doongjun/commitmon/{api => animation}/AdventureController.kt (96%) diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AdventureController.kt b/src/main/kotlin/com/doongjun/commitmon/animation/AdventureController.kt similarity index 96% rename from src/main/kotlin/com/doongjun/commitmon/api/AdventureController.kt rename to src/main/kotlin/com/doongjun/commitmon/animation/AdventureController.kt index 771773e..10cbd66 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/AdventureController.kt +++ b/src/main/kotlin/com/doongjun/commitmon/animation/AdventureController.kt @@ -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 diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt index e83e64e..2ac6541 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -36,7 +36,6 @@ class AccountController( @RequestParam code: String, ): ResponseEntity { val accessToken = accountFacade.login(code) - return ResponseEntity .status(HttpStatus.TEMPORARY_REDIRECT) .header( diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt index 5aeee21..409f207 100644 --- a/src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt @@ -1,6 +1,6 @@ package com.doongjun.commitmon.core.error -import com.doongjun.commitmon.api.AdventureController +import com.doongjun.commitmon.animation.AdventureController import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.MissingServletRequestParameterException From 7ff0381fbae3d81d516c0365688f214e2af11cef Mon Sep 17 00:00:00 2001 From: doongjun Date: Mon, 18 Nov 2024 11:29:31 +0900 Subject: [PATCH 5/8] =?UTF-8?q?#50=20config:=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doongjun/commitmon/config/AsyncConfig.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt b/src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt index d244dd2..ad81734 100644 --- a/src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt +++ b/src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt @@ -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 + } +} From d88405e5c0f17d39dee58543d9a17be676d611f7 Mon Sep 17 00:00:00 2001 From: doongjun Date: Mon, 18 Nov 2024 14:02:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?#50=20feat:=20RefreshToken=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../commitmon/api/AccountController.kt | 17 +++++++++-- .../commitmon/api/data/RefreshTokenRequest.kt | 8 ++++++ .../api/data/RefreshTokenResponse.kt | 13 +++++++++ .../doongjun/commitmon/app/AccountFacade.kt | 28 +++++++++++++++++-- .../doongjun/commitmon/app/data/AuthDto.kt | 6 ++++ .../commitmon/config/security/RefreshToken.kt | 17 +++++++++++ .../config/security/RefreshTokenRepository.kt | 5 ++++ .../config/security/TokenProvider.kt | 27 ++++++++++++++---- .../core/error/GlobalExceptionHandler.kt | 8 ++++++ 10 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt diff --git a/build.gradle.kts b/build.gradle.kts index c1b4422..6829bac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { 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") diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt index 2ac6541..eafb7b3 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -1,13 +1,18 @@ 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 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 @@ -35,12 +40,20 @@ class AccountController( @PathVariable destination: RedirectDestination, @RequestParam code: String, ): ResponseEntity { - val accessToken = accountFacade.login(code) + val (accessToken, refreshToken) = accountFacade.login(code) return ResponseEntity .status(HttpStatus.TEMPORARY_REDIRECT) .header( HttpHeaders.LOCATION, - "${destination.clientUrl}?accessToken=$accessToken", + "${destination.clientUrl}?accessToken=$accessToken&refreshToken=$refreshToken", ).build() } + + @PostMapping("/refresh") + fun refresh( + @Valid @RequestBody request: RefreshTokenRequest, + ): ResponseEntity { + val (accessToken, refreshToken) = accountFacade.refresh(token = request.refreshToken!!) + return ResponseEntity.ok(RefreshTokenResponse.of(accessToken, refreshToken)) + } } diff --git a/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt new file mode 100644 index 0000000..04ab34c --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt @@ -0,0 +1,8 @@ +package com.doongjun.commitmon.api.data + +import jakarta.validation.constraints.NotNull + +data class RefreshTokenRequest( + @field:NotNull + val refreshToken: String?, +) diff --git a/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt new file mode 100644 index 0000000..b34ad37 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt index 15591b3..a8633ac 100644 --- a/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt +++ b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt @@ -1,8 +1,10 @@ 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 @@ -12,11 +14,14 @@ class AccountFacade( private val githubOAuth2Service: GithubOAuth2Service, private val tokenProvider: TokenProvider, ) { - fun login(code: String): String { + fun login(code: String): AuthDto { val userLogin = githubOAuth2Service.getUserLogin(code) val user = getOrCreateUser(userLogin) - return tokenProvider.createAccessToken(user.id) + return AuthDto( + accessToken = tokenProvider.createAccessToken(user.id), + refreshToken = tokenProvider.createRefreshToken(user.id), + ) } private fun getOrCreateUser(username: String): GetUserDto = @@ -39,4 +44,23 @@ class AccountFacade( } 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!! + }, + ) + } } diff --git a/src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt b/src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt new file mode 100644 index 0000000..d962117 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt @@ -0,0 +1,6 @@ +package com.doongjun.commitmon.app.data + +data class AuthDto( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt new file mode 100644 index 0000000..19d2a05 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt @@ -0,0 +1,17 @@ +package com.doongjun.commitmon.config.security + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import java.util.UUID + +@RedisHash(value = "refreshToken") +data class RefreshToken( + @Id + var token: String? = UUID.randomUUID().toString(), + @TimeToLive + val ttl: Long = 1209600, + val userId: Long?, +) { + fun isLessThanWeek(): Boolean = ttl < 604800 +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt new file mode 100644 index 0000000..bc26eed --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt @@ -0,0 +1,5 @@ +package com.doongjun.commitmon.config.security + +import org.springframework.data.repository.CrudRepository + +interface RefreshTokenRepository : CrudRepository diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt index fe32373..89d2d1e 100644 --- a/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt @@ -8,23 +8,28 @@ import io.jsonwebtoken.io.Decoders import io.jsonwebtoken.security.Keys import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Component import java.util.Date @Component class TokenProvider( + private val refreshTokenRepository: RefreshTokenRepository, @Value("\${app.auth.jwt.base64-secret}") private val base64Secret: String, @Value("\${app.auth.jwt.expired-ms}") - private val expiredMs: Long, + private val jwtExpiredMs: Long, + @Value("\${app.auth.refresh-token.expired-ms}") + private val refreshTokenExpiredMs: Long, ) { private val log = LoggerFactory.getLogger(javaClass) fun createAccessToken(userId: Long): String { val now = Date() - val expiration = Date(now.time + expiredMs) + val expiration = Date(now.time + jwtExpiredMs) - return Jwts.builder() + return Jwts + .builder() .claims(mapOf("sub" to userId.toString())) .issuedAt(now) .expiration(expiration) @@ -34,7 +39,8 @@ class TokenProvider( fun extractUserId(accessToken: String): Long { val claims = - Jwts.parser() + Jwts + .parser() .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) .build() .parseSignedClaims(accessToken) @@ -44,7 +50,8 @@ class TokenProvider( fun validateAccessToken(accessToken: String): Boolean { try { - Jwts.parser() + Jwts + .parser() .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret))) .build() .parseSignedClaims(accessToken) @@ -61,4 +68,14 @@ class TokenProvider( } return false } + + fun createRefreshToken(userId: Long) = + refreshTokenRepository + .save( + RefreshToken(userId = userId, ttl = refreshTokenExpiredMs / 1000), + ).token!! + + fun expireRefreshToken(refreshToken: String) = refreshTokenRepository.deleteById(refreshToken) + + fun getRefreshToken(refreshToken: String) = refreshTokenRepository.findByIdOrNull(refreshToken) } diff --git a/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt index c7a778e..43a4a71 100644 --- a/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt @@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.AccountExpiredException import org.springframework.validation.BindException import org.springframework.web.HttpRequestMethodNotSupportedException import org.springframework.web.bind.MethodArgumentNotValidException @@ -76,6 +77,13 @@ class GlobalExceptionHandler { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response) } + @ExceptionHandler(AccountExpiredException::class) + protected fun handleAccountExpiredException(e: AccountExpiredException): ResponseEntity { + log.error("AccessDeniedException", e) + val response = ErrorResponse.of(ErrorCode.UNAUTHORIZED) + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response) + } + @ExceptionHandler(IllegalArgumentException::class) protected fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity { log.error("IllegalArgumentException", e) From a9c8aa64d0bae8441147b3eebf3ad2537935e650 Mon Sep 17 00:00:00 2001 From: doongjun Date: Mon, 18 Nov 2024 14:24:30 +0900 Subject: [PATCH 7/8] =?UTF-8?q?#50=20config:=20Swagger=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ .../commitmon/api/AccountController.kt | 6 +++- .../commitmon/api/data/RedirectDestination.kt | 14 +++++--- .../commitmon/config/SwaggerConfig.kt | 36 +++++++++++++++++++ .../config/security/SecurityConfig.kt | 14 +++++--- 5 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6829bac..c417818 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { 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") diff --git a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt index eafb7b3..a4483be 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -5,6 +5,7 @@ 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 @@ -24,6 +25,7 @@ class AccountController( private val accountFacade: AccountFacade, private val githubOAuth2Service: GithubOAuth2Service, ) { + @Operation(summary = "로그인") @GetMapping("/login") fun login( @RequestHeader("Redirect-Destination", defaultValue = "LOCAL") destination: RedirectDestination, @@ -35,6 +37,7 @@ class AccountController( githubOAuth2Service.getRedirectUrl(destination), ).build() + @Operation(summary = "로그인 Callback (시스템에서 호출)") @GetMapping("/oauth/github/callback/{destination}") fun loginCallback( @PathVariable destination: RedirectDestination, @@ -45,10 +48,11 @@ class AccountController( .status(HttpStatus.TEMPORARY_REDIRECT) .header( HttpHeaders.LOCATION, - "${destination.clientUrl}?accessToken=$accessToken&refreshToken=$refreshToken", + destination.getClientUrl(accessToken, refreshToken), ).build() } + @Operation(summary = "토큰 갱신") @PostMapping("/refresh") fun refresh( @Valid @RequestBody request: RefreshTokenRequest, diff --git a/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt index 5c15aa8..dd3988f 100644 --- a/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt @@ -2,14 +2,20 @@ package com.doongjun.commitmon.api.data enum class RedirectDestination( val callbackUrl: String, - val clientUrl: String, + private val clientUrl: String, ) { PRODUCTION( "https://commitmon.me/api/v1/account/oauth/github/callback/PRODUCTION", - "https://commitmon-client.vercel.app", + "https://commitmon-client.vercel.app/account", ), LOCAL( - "http://localhost:8080/api/v1/account/oauth/github/callback/LOCAL", - "http://localhost:3000", + "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" } diff --git a/src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt b/src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt new file mode 100644 index 0000000..03858bc --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt @@ -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() +} diff --git a/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt b/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt index 0895ad7..0900ce2 100644 --- a/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt @@ -22,11 +22,15 @@ class SecurityConfig( .addFilterBefore(JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter::class.java) .authorizeHttpRequests { authorize -> authorize - .requestMatchers("/adventure").permitAll() - .requestMatchers("/api/v1/account/**").permitAll() - .anyRequest().authenticated() - } - .exceptionHandling { exception -> + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .requestMatchers("/adventure") + .permitAll() + .requestMatchers("/api/v1/account/**") + .permitAll() + .anyRequest() + .authenticated() + }.exceptionHandling { exception -> exception .accessDeniedHandler(RestAccessDeniedHandler()) .authenticationEntryPoint(RestAuthenticationEntryPoint()) From 25319c0b4627bb94bc8b215784067a2845c5c015 Mon Sep 17 00:00:00 2001 From: doongjun Date: Mon, 18 Nov 2024 14:37:27 +0900 Subject: [PATCH 8/8] #50 rename: lint --- .../com/doongjun/commitmon/extension/StringExtension.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt b/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt index c7e9401..0560881 100644 --- a/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt +++ b/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt @@ -3,9 +3,7 @@ package com.doongjun.commitmon.extension import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -fun Any.convertToString(): String { - return jacksonObjectMapper().writeValueAsString(this) -} +fun Any.convertToString(): String = jacksonObjectMapper().writeValueAsString(this) fun String.convertToObject(): T { val typeReference = object : TypeReference() {}