diff --git a/build.gradle.kts b/build.gradle.kts index 90fa6b2..c417818 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") 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 new file mode 100644 index 0000000..a4483be --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt @@ -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 = + 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 { + 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 { + 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/RedirectDestination.kt b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt new file mode 100644 index 0000000..dd3988f --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt @@ -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" +} 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 new file mode 100644 index 0000000..a8633ac --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt @@ -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!! + }, + ) + } +} 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/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/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 + } +} 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/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/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/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/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..0900ce2 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt @@ -0,0 +1,41 @@ +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("/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .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..89d2d1e --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt @@ -0,0 +1,81 @@ +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.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 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 + jwtExpiredMs) + + 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 + } + + 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/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 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..43a4a71 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt @@ -0,0 +1,93 @@ +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.security.authentication.AccountExpiredException +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) + } + + @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) + 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/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) + } + } +} 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..0560881 --- /dev/null +++ b/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt @@ -0,0 +1,11 @@ +package com.doongjun.commitmon.extension + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +fun Any.convertToString(): String = jacksonObjectMapper().writeValueAsString(this) + +fun String.convertToObject(): T { + val typeReference = object : TypeReference() {} + return jacksonObjectMapper().readValue(this, typeReference) +} 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..322da19 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,16 @@ 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 \ 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