Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.SUBMODULE_TOKEN }}

- name: Set up JDK 21
uses: actions/setup-java@v4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ jobs:
- name: Checkout (for tagging)
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
token: ${{ secrets.SUBMODULE_TOKEN }}

- name: Calculate version
id: calc
Expand Down Expand Up @@ -94,5 +95,5 @@ jobs:
--name moa-test-server \
--restart unless-stopped \
-p 8080:8080 \
-e PROFILE= \
-e PROFILE=prod\
-d godqhr721/moa_server:${{ needs.setup.outputs.docker_tag }}
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "src/main/resources/moa-secret"]
path = src/main/resources/moa-secret
url = https://github.com/subsub97/moa-secret
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ dependencies {
// db
implementation("org.springframework.boot:spring-boot-h2console")
runtimeOnly("com.h2database:h2")
// runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("com.mysql:mysql-connector-j")

//jwt
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
implementation("io.jsonwebtoken:jjwt-impl:0.13.0")
implementation("io.jsonwebtoken:jjwt-jackson:0.13.0")

// etc
implementation("org.jetbrains.kotlin:kotlin-reflect")
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
2 changes: 2 additions & 0 deletions src/main/kotlin/com/moa/Application.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.moa

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class Application

fun main(args: Array<String>) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/com/moa/common/auth/AuthConstants.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.moa.common.auth

object AuthConstants {
const val CURRENT_MEMBER_ID = "currentMemberId"
}
9 changes: 9 additions & 0 deletions src/main/kotlin/com/moa/common/auth/AuthenticatedMember.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.moa.common.auth

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class AuthenticatedMember

data class AuthenticatedMemberInfo(
val id: Long,
)
34 changes: 34 additions & 0 deletions src/main/kotlin/com/moa/common/auth/AuthenticatedMemberResolver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.moa.common.auth

import com.moa.common.exception.BadRequestException
import com.moa.common.exception.ErrorCode
import org.springframework.core.MethodParameter
import org.springframework.stereotype.Component
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer

@Component
class AuthenticatedMemberResolver : HandlerMethodArgumentResolver {

override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(AuthenticatedMember::class.java) &&
parameter.parameterType == AuthenticatedMemberInfo::class.java
}

override fun resolveArgument(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow 재밋다잉~

parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): AuthenticatedMemberInfo {
val memberId = webRequest.getAttribute(
AuthConstants.CURRENT_MEMBER_ID,
RequestAttributes.SCOPE_REQUEST
) as? Long ?: throw BadRequestException(ErrorCode.INVALID_ID_TOKEN)

return AuthenticatedMemberInfo(id = memberId)
}
}
69 changes: 69 additions & 0 deletions src/main/kotlin/com/moa/common/auth/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.moa.common.auth

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*

@Component
class JwtTokenProvider(
@Value("\${jwt.secret-key}")
private val accessTokenSecretKey: String,

@Value("\${jwt.expiration-milliseconds}")
private val accessTokenExpirationInMilliseconds: Long,
) {

private val accessKey = Keys.hmacShaKeyFor(accessTokenSecretKey.toByteArray(StandardCharsets.UTF_8))

fun createAccessToken(userId: Long): String {
val now = LocalDateTime.now()
val expiryDate = now.plus(Duration.ofMillis(accessTokenExpirationInMilliseconds))

return Jwts.builder()
.subject(userId.toString())
.issuedAt(toDate(now))
.expiration(toDate(expiryDate))
.signWith(accessKey)
.compact()
}

fun extractToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")
return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
bearerToken.substring(7)
} else null
}

fun getUserIdFromToken(token: String): Long? {
return getClaims(token).subject.toLong()
}

fun validateToken(token: String): Boolean {
return try {
getClaims(token)
true
} catch (ex: Exception) {
false
}
}

private fun getClaims(token: String): Claims {
return Jwts.parser()
.verifyWith(accessKey)
.build()
.parseSignedClaims(token)
.payload
}
}

fun toDate(localDateTime: LocalDateTime): Date {
return Date.from(localDateTime.atZone(ZoneId.of("Asia/Seoul")).toInstant())
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/moa/common/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.moa.common.config

import com.moa.common.auth.AuthenticatedMemberResolver
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig(
private val authenticatedMemberResolver: AuthenticatedMemberResolver,
) : WebMvcConfigurer {

override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(authenticatedMemberResolver)
}
}
4 changes: 4 additions & 0 deletions src/main/kotlin/com/moa/common/exception/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ enum class ErrorCode(
INVALID_PAYROLL_INPUT("INVALID_PAYROLL_INPUT", "급여 입력값이 유효하지 않습니다"),
INVALID_WORK_POLICY_INPUT("INVALID_WORK_POLICY_INPUT", "근무정책 입력값이 유효하지 않습니다"),
REQUIRED_TERMS_MUST_BE_AGREED("REQUIRED_TERMS_MUST_BE_AGREED", "필수 약관은 동의해야 합니다"),

INVALID_ID_TOKEN("INVALID_ID_TOKEN", "유효하지 않은 ID 토큰입니다"),
INVALID_PROVIDER("INVALID_PROVIDER", "유효하지 않는 로그인 방식입니다."),
OIDC_PROVIDER_ERROR("OIDC_PROVIDER_ERROR", "인증 제공자 연동 중 오류가 발생했습니다"),
}
12 changes: 12 additions & 0 deletions src/main/kotlin/com/moa/common/exception/GlobalExceptionHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.moa.common.exception

import com.moa.common.response.ApiResponse
import com.moa.common.response.FieldError
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
Expand All @@ -15,6 +16,8 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep
@RestControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {

private val log = LoggerFactory.getLogger(this::class.java)

@ExceptionHandler(NotFoundException::class)
fun handleNotFoundException(ex: NotFoundException): ResponseEntity<ApiResponse<Unit>> {
return ResponseEntity
Expand Down Expand Up @@ -43,6 +46,15 @@ class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
.body(ApiResponse.error(ex.errorCode))
}

@ExceptionHandler(RuntimeException::class)
fun handleRuntimeException(ex: RuntimeException): ResponseEntity<ApiResponse<Unit>> {
log.error("Unhandled Exception occurred: ${ex.message}", ex)

return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR))
}

override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
Expand Down
68 changes: 68 additions & 0 deletions src/main/kotlin/com/moa/common/filter/JwtAuthenticationFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.moa.common.filter

import com.moa.common.auth.AuthConstants
import com.moa.common.auth.JwtTokenProvider
import com.moa.common.exception.ErrorCode
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import tools.jackson.databind.ObjectMapper

@Component
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
private val objectMapper: ObjectMapper,
) : OncePerRequestFilter() {

companion object {
private val EXCLUDED_PATHS = listOf(
"/api/v1/auth",
"/h2-console",
)
}

override fun shouldNotFilter(request: HttpServletRequest): Boolean {
val path = request.requestURI
return EXCLUDED_PATHS.any { path.startsWith(it) }
}

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

if (token == null || !jwtTokenProvider.validateToken(token)) {
writeUnauthorizedResponse(response)
return
}

val memberId = jwtTokenProvider.getUserIdFromToken(token)
if (memberId == null) {
writeUnauthorizedResponse(response)
return
}

request.setAttribute(AuthConstants.CURRENT_MEMBER_ID, memberId)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오오오 이렇게 했구나~~ 구우웃!

filterChain.doFilter(request, response)
}

private fun writeUnauthorizedResponse(response: HttpServletResponse) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"

val errorCode = ErrorCode.UNAUTHORIZED
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 jackson 쓰면 깔끔해질텐데~! 🙋

val errorResponse = mapOf(
"code" to errorCode.code,
"message" to errorCode.message,
"content" to null
)

objectMapper.writeValue(response.writer, errorResponse)
}
}
52 changes: 52 additions & 0 deletions src/main/kotlin/com/moa/common/oidc/OidcClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.moa.common.oidc

import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import java.math.BigInteger
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.RSAPublicKeySpec
import java.util.*

@Component
class OidcClient(
private val restClient: RestClient = RestClient.create(),
) {
fun fetchPublicKeys(jwksUri: String): Map<String, RSAPublicKey> {
val response = try {
restClient.get()
.uri(jwksUri)
.retrieve()
.body(JwksResponse::class.java)
} catch (ex: Exception) {
throw RuntimeException("OIDC 공개키를 가져오는데 실패했습니다.", ex)
}

return response?.keys
?.filter { it.kty == "RSA" && it.use == "sig" }
?.associate { key ->
key.kid to createRsaPublicKey(key.n, key.e)
} ?: emptyMap()
}

private fun createRsaPublicKey(n: String, e: String): RSAPublicKey {
val decoder = Base64.getUrlDecoder()
val modulus = BigInteger(1, decoder.decode(n))
val exponent = BigInteger(1, decoder.decode(e))
val spec = RSAPublicKeySpec(modulus, exponent)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePublic(spec) as RSAPublicKey
}

private data class JwksResponse(
val keys: List<JwkKey>,
)

private data class JwkKey(
val kid: String,
val kty: String,
val use: String?,
val n: String,
val e: String,
)
}
Loading
Loading