Initialize RTGS prototype repository

3센터 A-A-A RTGS 실시간총액결제 프로토타입 최초 버전관리 시작.
- backend: Kotlin/Gradle 멀티모듈(sequencer, common, 채널/센터 모듈)
- frontend: Vite + TS
- infra: docker-compose, prometheus/grafana, ELK 네이티브 스크립트
- loadtest(k6), sim(장애/순서/정합성 시나리오), docs(PoC 보고서/복원력방안)
This commit is contained in:
rtgs
2026-07-15 15:37:32 +09:00
commit 58ca23b5d9
140 changed files with 11832 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
/** 로그인 실패(자격증명 불일치). */
class AuthFailedException(msg: String) : RuntimeException(msg)
/**
* 참가기관/관리자 로그인 인증. 자격증명(비밀키)은 **LouisVuitton이 관리하는 app_user**(권한 마스터)에서
* 확인한다(인증=Gucci, 권한마스터=LouisVuitton). 비밀키는 **BCrypt 해시**로 저장·대조. 인증 성공 시 JWT 발급.
*/
@Service
class AuthService(
private val jdbc: JdbcTemplate,
private val jwt: JwtService,
private val encoder: PasswordEncoder,
) {
private val log = LoggerFactory.getLogger(javaClass)
data class LoginResult(
val token: String, val role: String, val orgCode: String?,
val expiresIn: Long, val mustChangePassword: Boolean,
)
fun login(username: String, secret: String, nowMs: Long): LoginResult {
val rows = jdbc.queryForList(
"SELECT role, org_code, secret, must_change_password FROM app_user WHERE username = ?", username,
)
val row = rows.firstOrNull() ?: run {
log.warn("AUTH login FAIL user={} reason=unknown-user", username)
throw AuthFailedException("invalid credentials")
}
val stored = row["secret"] as? String
if (stored.isNullOrBlank() || !encoder.matches(secret, stored)) {
log.warn("AUTH login FAIL user={} reason=bad-secret", username)
throw AuthFailedException("invalid credentials")
}
val role = row["role"] as? String ?: ""
val orgCode = row["org_code"] as? String
val mustChange = row["must_change_password"] as? Boolean ?: false
val token = jwt.issue(Principal(username, role, orgCode), nowMs)
log.info("AUTH login OK user={} role={} org={} mustChange={}", username, role, orgCode, mustChange)
return LoginResult(token, role, orgCode, jwt.ttl(), mustChange)
}
/** 초기암호(또는 비밀번호) 변경. 기존 비번 확인 후 새 비번(BCrypt) 저장 + 변경필요 해제. */
fun changePassword(username: String, oldSecret: String, newSecret: String) {
if (newSecret.length < 1) throw AuthFailedException("new secret too short")
val rows = jdbc.queryForList("SELECT secret FROM app_user WHERE username = ?", username)
val stored = rows.firstOrNull()?.get("secret") as? String
?: throw AuthFailedException("invalid credentials")
if (!encoder.matches(oldSecret, stored)) {
log.warn("AUTH changePw FAIL user={} reason=bad-old", username)
throw AuthFailedException("invalid credentials")
}
jdbc.update(
"UPDATE app_user SET secret = ?, must_change_password = false WHERE username = ?",
encoder.encode(newSecret), username,
)
log.info("AUTH changePw OK user={}", username)
}
}

View File

@@ -0,0 +1,71 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
/**
* 콜백 레지스트리 관리(관리자) + 기관 수신 sink(데모).
* - 레지스트리: 기관별 결과통보 수신 URL 조회/등록.
* - sink: 로컬 데모용 "기관 수신 서버" 모사 — pacs.002를 받아 ACK(200) 응답.
*/
@RestController
class CallbackController(
private val jdbc: JdbcTemplate,
private val jwt: JwtService,
) {
private val log = LoggerFactory.getLogger(javaClass)
data class CallbackReq(val callbackUrl: String = "", val active: Boolean = true)
/** 콜백 레지스트리 목록(관리자). */
@GetMapping("/gucci/callbacks", produces = [MediaType.APPLICATION_JSON_VALUE])
fun list(@RequestHeader("Authorization", required = false) authz: String?): List<Map<String, Any?>> {
requireAdmin(authz)
return jdbc.queryForList("SELECT org_code, callback_url, active FROM institution_endpoint ORDER BY org_code")
}
/** 기관 콜백 URL 등록/수정(관리자). */
@PutMapping("/gucci/callbacks/{org}", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun upsert(
@PathVariable org: String,
@RequestBody req: CallbackReq,
@RequestHeader("Authorization", required = false) authz: String?,
): Map<String, Any?> {
requireAdmin(authz)
jdbc.update(
"INSERT INTO institution_endpoint(org_code, callback_url, active, created_at) VALUES (?,?,?,?) " +
"ON CONFLICT (org_code) DO UPDATE SET callback_url = EXCLUDED.callback_url, active = EXCLUDED.active",
org, req.callbackUrl, req.active, System.currentTimeMillis(),
)
log.info("CALLBACK upsert org={} url={} active={}", org, req.callbackUrl, req.active)
return mapOf("ok" to true, "orgCode" to org, "callbackUrl" to req.callbackUrl, "active" to req.active)
}
/**
* 기관 수신 sink(데모) — 실제 참가기관 수신 서버 대역. pacs.002를 받아 로그 후 ACK.
* 로컬 왕복 시연용(운영에선 각 기관의 실제 엔드포인트).
*/
@PostMapping("/gucci/sink/{org}", consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
fun sink(@PathVariable org: String, @RequestBody body: String): Map<String, Any?> {
log.info("SINK 수신 org={} bytes={} (기관 수신 모사 → ACK)", org, body.length)
return mapOf("ack" to true, "org" to org, "received" to body.length)
}
private fun requireAdmin(authz: String?) {
val token = authz?.removePrefix("Bearer ")?.trim()
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
val p = try { jwt.verify(token, System.currentTimeMillis()) }
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token") }
if (p.role != "ADMIN") throw ResponseStatusException(HttpStatus.FORBIDDEN, "admin only")
}
}

View File

@@ -0,0 +1,66 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.web.client.RestClient
/**
* G2 결과 콜백 송부(전달 보장) — Chanel이 아웃박스(notification, delivered=false)에 적재한 결과 전문을
* 기관별 콜백 URL로 **POST 송부**하고, 2xx ACK 시 delivered=true로 확정한다. 실패는 attempts를 늘려
* 다음 주기에 **재시도**하며, 최대 시도 초과분은 보류(운영 알람 대상). 폴링 아웃박스 방식이라
* 서비스 재기동에도 유실 없이 이어서 송부한다(at-least-once + 수신측 dedup 전제).
*/
@Service
class DeliveryService(
private val jdbc: JdbcTemplate,
private val http: RestClient,
@Value("\${gucci.delivery-max-attempts:5}") private val maxAttempts: Int,
@Value("\${gucci.delivery-batch:50}") private val batch: Int,
) {
private val log = LoggerFactory.getLogger(javaClass)
@Scheduled(fixedDelayString = "\${gucci.delivery-interval-ms:3000}")
fun deliverPending() {
val rows = jdbc.queryForList(
"SELECT id, to_org, pacs002_xml, attempts FROM notification " +
"WHERE delivered = false AND attempts < ? ORDER BY created_at LIMIT ?",
maxAttempts, batch,
)
if (rows.isEmpty()) return
for (r in rows) {
val id = r["id"] as String
val toOrg = r["to_org"] as String
val xml = r["pacs002_xml"] as String
val attempts = (r["attempts"] as Number).toInt()
val url = endpointOf(toOrg)
if (url == null) {
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
log.warn("DELIVER skip id={} org={} reason=no-endpoint attempts={}", id, toOrg, attempts + 1)
continue
}
try {
http.post().uri(url).contentType(MediaType.APPLICATION_XML)
.body(xml).retrieve().toBodilessEntity() // 2xx ACK 아니면 예외
jdbc.update(
"UPDATE notification SET delivered = true, attempts = attempts + 1, delivered_at = ? WHERE id = ?",
System.currentTimeMillis(), id,
)
log.info("DELIVER ok id={} org={} url={}", id, toOrg, url)
} catch (e: Exception) {
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
log.warn("DELIVER fail id={} org={} attempts={} err={}", id, toOrg, attempts + 1, e.message)
}
}
}
private fun endpointOf(orgCode: String): String? =
jdbc.queryForList(
"SELECT callback_url FROM institution_endpoint WHERE org_code = ? AND active = true", orgCode,
).firstOrNull()?.get("callback_url") as? String
}

View File

@@ -0,0 +1,56 @@
package kr.or.bok.rtgs.gucci
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* 유량제어(rate limit) — subject(기관)별 고정 윈도우 카운터. 순번기 상한·코어 보호(성능목표).
* 프로토타입은 인메모리(센터 로컬). 다센터·다인스턴스는 분산 카운터(Redis 등)로 확장.
*/
@Component
class RateLimiter(
@Value("\${gucci.rate-limit-per-window:50}") private val limit: Int,
@Value("\${gucci.rate-window-seconds:10}") private val windowSeconds: Long,
) {
private class Window(val startSec: Long) { val count = AtomicInteger(0) }
private val windows = ConcurrentHashMap<String, Window>()
/** 허용이면 true. 초과면 false(429). nowMs 주입(테스트 용이). */
fun allow(subject: String, nowMs: Long): Boolean {
val bucket = nowMs / 1000 / windowSeconds
val w = windows.compute(subject) { _, cur ->
if (cur == null || cur.startSec != bucket) Window(bucket) else cur
}!!
return w.count.incrementAndGet() <= limit
}
}
/**
* 재전송(replay) 방지 — 요청별 nonce+timestamp. 시각오차 초과 또는 nonce 재사용이면 거부.
* 부인방지(원전문 해시)와 함께 이중제출·재생 공격 차단.
*/
@Component
class ReplayGuard(
@Value("\${gucci.replay-skew-seconds:30}") private val skewSeconds: Long,
@Value("\${gucci.replay-nonce-ttl-seconds:120}") private val nonceTtlSeconds: Long,
) {
private val seen = ConcurrentHashMap<String, Long>() // nonce -> 만료 epochSec
sealed class Result { object Ok : Result(); data class Reject(val reason: String) : Result() }
/** 검사 + 통과 시 nonce 등록. nowMs 주입. */
fun check(nonce: String?, timestampSec: Long?, nowMs: Long): Result {
if (nonce.isNullOrBlank() || timestampSec == null) return Result.Reject("missing nonce/timestamp")
val nowSec = nowMs / 1000
if (kotlin.math.abs(nowSec - timestampSec) > skewSeconds) return Result.Reject("timestamp out of window")
prune(nowSec)
val prev = seen.putIfAbsent(nonce, nowSec + nonceTtlSeconds)
return if (prev != null) Result.Reject("nonce replayed") else Result.Ok
}
private fun prune(nowSec: Long) {
if (seen.size > 10_000) seen.entries.removeIf { it.value < nowSec }
}
}

View File

@@ -0,0 +1,32 @@
package kr.or.bok.rtgs.gucci
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.client.RestClient
/**
* GUCCI (Global User Communication Control Interface) — 외부 경계 관문(북-남 트래픽).
*
* 로컬 단계 G1: 참가기관 로그인 인증(JWT 발급) + API 인증(토큰·서명·재전송 차단) + 유량제어 +
* 코어(Chanel) 리버스프록시 + 감사로그. 센터간 헬스체크·정족수/펜싱·GSLB 라우팅은 다센터(G3) 단계.
*
* 원칙: **전역 결제순서는 Sequencer 단독**, Gucci는 관문/라우팅/유량제어만(순서 결정 아님).
*/
@SpringBootApplication
@EnableScheduling
class GucciApplication {
@Bean
fun restClient(): RestClient = RestClient.create()
/** 비밀키 해시(BCrypt+솔트). 로그인 검증·자격증명 마이그레이션에 공용. */
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
fun main(args: Array<String>) {
runApplication<GucciApplication>(*args)
}

View File

@@ -0,0 +1,166 @@
package kr.or.bok.rtgs.gucci
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
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.RestController
import org.springframework.web.client.RestClient
import org.springframework.web.server.ResponseStatusException
/**
* GUCCI 관문 API — 인증(로그인·JWT) + 인증된 리버스프록시(→ Chanel) + 유량제어 + 재전송 차단 + 감사로그.
* 참가기관은 Gucci(:8095)를 통해 접수/조회하고, 코어(Chanel:8091)는 내부에 둔다.
*/
@RestController
class GucciController(
private val auth: AuthService,
private val jwt: JwtService,
private val rateLimiter: RateLimiter,
private val replayGuard: ReplayGuard,
private val http: RestClient,
@Value("\${gucci.chanel-base-url}") private val chanelBase: String,
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
) {
private val log = LoggerFactory.getLogger(javaClass)
private fun now() = System.currentTimeMillis()
data class LoginReq(val username: String = "", val secret: String = "")
data class ChangePwReq(val username: String = "", val oldSecret: String = "", val newSecret: String = "")
/** ① 로그인 인증 → JWT 발급. */
@PostMapping("/gucci/auth/login")
fun login(@RequestBody req: LoginReq): Map<String, Any?> = try {
val r = auth.login(req.username, req.secret, now())
mapOf("token" to r.token, "role" to r.role, "orgCode" to r.orgCode,
"tokenType" to "Bearer", "expiresIn" to r.expiresIn, "mustChangePassword" to r.mustChangePassword)
} catch (e: AuthFailedException) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
}
/** ①-2 초기암호/비밀번호 변경. */
@PostMapping("/gucci/auth/change-password")
fun changePassword(@RequestBody req: ChangePwReq): Map<String, Any?> = try {
auth.changePassword(req.username, req.oldSecret, req.newSecret)
mapOf("ok" to true)
} catch (e: AuthFailedException) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
}
/** ② 자금이체 신청(접수) — 인증·유량·재전송·기관바인딩 검사 후 Chanel로 전달. */
@PostMapping(
"/gucci/pay/customer",
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
produces = [MediaType.APPLICATION_XML_VALUE],
)
fun pay(
@RequestBody rawXml: String,
@RequestHeader(value = "Authorization", required = false) authz: String?,
@RequestHeader(value = "X-Nonce", required = false) nonce: String?,
@RequestHeader(value = "X-Timestamp", required = false) timestamp: String?,
): String {
val p = authenticate(authz)
requireRole(p, "ORG_S", "ADMIN") // 송신기관만 신청
enforceRate(p)
enforceReplay(p, nonce, timestamp)
enforceOrgBinding(p, rawXml) // 토큰 기관 == 전문 송신기관
audit("PAY", p, "allow", null)
return forwardPost("/pay/customer", rawXml)
}
/** ③ 거래 상태 조회 — 인증된 참가기관/관리자. */
@GetMapping("/gucci/inquiry/{bmi}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun inquiry(@PathVariable bmi: String, @RequestHeader("Authorization", required = false) authz: String?): String {
val p = authenticate(authz); enforceRate(p)
audit("INQUIRY", p, "allow", bmi)
return forwardGet("/inquiry/$bmi")
}
/** ④ 참가기관 잔액 목록 — 관리자만(대사·모니터링). */
@GetMapping("/gucci/accounts", produces = [MediaType.APPLICATION_JSON_VALUE])
fun accounts(@RequestHeader("Authorization", required = false) authz: String?): String {
val p = authenticate(authz); requireRole(p, "ADMIN"); enforceRate(p)
audit("ACCOUNTS", p, "allow", null)
return forwardGet("/accounts")
}
/** ⑤ 센터 헬스체크(관측용). 로컬은 단일 센터. 다센터·정족수/펜싱은 G3. */
@GetMapping("/gucci/health/centers", produces = [MediaType.APPLICATION_JSON_VALUE])
fun centers(): Map<String, Any?> {
val chanelUp = runCatching { http.get().uri("$chanelBase/accounts").retrieve().toBodilessEntity() }.isSuccess
return mapOf(
"self" to centerId,
"centers" to listOf(mapOf("id" to centerId, "core(chanel)" to if (chanelUp) "UP" else "DOWN")),
"note" to "단일 센터(G1). 다센터 정족수/펜싱은 G3(고사양 PC 이후).",
)
}
// ---------- 내부 검사·전달 ----------
private fun authenticate(authz: String?): Principal {
val token = authz?.removePrefix("Bearer ")?.trim()
if (token.isNullOrBlank()) throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
return try { jwt.verify(token, now()) }
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token: ${e.message}") }
}
private fun requireRole(p: Principal, vararg allowed: String) {
if (p.role !in allowed) {
audit("AUTHZ", p, "deny", "role ${p.role} not in ${allowed.toList()}")
throw ResponseStatusException(HttpStatus.FORBIDDEN, "role not permitted")
}
}
private fun enforceRate(p: Principal) {
if (!rateLimiter.allow(p.subject, now())) {
audit("RATE", p, "deny", "rate limit exceeded")
throw ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "rate limit exceeded")
}
}
private fun enforceReplay(p: Principal, nonce: String?, timestamp: String?) {
val ts = timestamp?.toLongOrNull()
when (val r = replayGuard.check(nonce, ts, now())) {
is ReplayGuard.Result.Reject -> {
audit("REPLAY", p, "deny", r.reason)
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "replay check failed: ${r.reason}")
}
else -> {}
}
}
/** 토큰의 기관코드와 전문 송신기관(DbtrAgt MmbId) 일치 강제(관리자 예외). */
private fun enforceOrgBinding(p: Principal, rawXml: String) {
if (p.role == "ADMIN") return
val sender = runCatching { Iso20022Codec.parsePacs008(rawXml).from }.getOrNull() ?: return // 파싱 실패는 Chanel XSD가 반려
if (p.orgCode != sender) {
audit("ORGBIND", p, "deny", "token org ${p.orgCode} != sender $sender")
throw ResponseStatusException(HttpStatus.FORBIDDEN, "token org does not match message sender")
}
}
private fun forwardPost(path: String, body: String): String = try {
http.post().uri("$chanelBase$path")
.contentType(MediaType.APPLICATION_XML)
.body(body).retrieve().body(String::class.java) ?: ""
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
}
private fun forwardGet(path: String): String = try {
http.get().uri("$chanelBase$path").retrieve().body(String::class.java) ?: ""
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
}
private fun audit(event: String, p: Principal, decision: String, detail: String?) {
log.info("AUDIT event={} subject={} role={} org={} decision={} detail={}",
event, p.subject, p.role, p.orgCode, decision, detail ?: "")
}
}

View File

@@ -0,0 +1,76 @@
package kr.or.bok.rtgs.gucci
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/** JWT 검증 실패(만료/서명오류/형식). */
class JwtException(msg: String) : RuntimeException(msg)
/** 토큰에서 복원한 인증 주체. */
data class Principal(val subject: String, val role: String, val orgCode: String?)
/**
* 경량 JWT(HS256) — 외부 라이브러리 없이 표준 구조(header.payload.signature)로 서명·검증.
* 프로토타입 자족 구현. 운영은 표준 라이브러리/키관리(HSM·회전)로 대체.
*/
@Component
class JwtService(
private val mapper: ObjectMapper,
@Value("\${gucci.jwt-secret}") private val secret: String,
@Value("\${gucci.jwt-ttl-seconds:1800}") private val ttlSeconds: Long,
) {
private val enc = Base64.getUrlEncoder().withoutPadding()
private val dec = Base64.getUrlDecoder()
fun ttl(): Long = ttlSeconds
/** 주체 정보로 토큰 발급(iat/exp 포함). nowMs는 호출측이 주입(테스트 용이). */
fun issue(p: Principal, nowMs: Long): String {
val header = mapOf("alg" to "HS256", "typ" to "JWT")
val iat = nowMs / 1000
val payload = mapOf(
"sub" to p.subject, "role" to p.role, "org" to p.orgCode,
"iat" to iat, "exp" to iat + ttlSeconds,
)
val h = enc.encodeToString(mapper.writeValueAsBytes(header))
val pl = enc.encodeToString(mapper.writeValueAsBytes(payload))
val signingInput = "$h.$pl"
return "$signingInput.${sign(signingInput)}"
}
/** 토큰 검증 후 주체 반환. 서명 불일치·만료·형식오류면 JwtException. */
fun verify(token: String, nowMs: Long): Principal {
val parts = token.split(".")
if (parts.size != 3) throw JwtException("malformed token")
val expected = sign("${parts[0]}.${parts[1]}")
if (!constantTimeEquals(expected, parts[2])) throw JwtException("bad signature")
val claims: Map<String, Any?> = mapper.readValue(dec.decode(parts[1]), Map::class.java)
.let { @Suppress("UNCHECKED_CAST") (it as Map<String, Any?>) }
val exp = (claims["exp"] as? Number)?.toLong() ?: throw JwtException("no exp")
if (nowMs / 1000 > exp) throw JwtException("expired")
return Principal(
subject = claims["sub"] as? String ?: throw JwtException("no sub"),
role = claims["role"] as? String ?: "",
orgCode = claims["org"] as? String,
)
}
private fun sign(input: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
return enc.encodeToString(mac.doFinal(input.toByteArray(StandardCharsets.UTF_8)))
}
private fun constantTimeEquals(a: String, b: String): Boolean {
val x = a.toByteArray(); val y = b.toByteArray()
if (x.size != y.size) return false
var r = 0
for (i in x.indices) r = r or (x[i].toInt() xor y[i].toInt())
return r == 0
}
}

View File

@@ -0,0 +1,35 @@
package kr.or.bok.rtgs.gucci
import org.slf4j.LoggerFactory
import org.springframework.boot.ApplicationRunner
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component
/**
* 자격증명 마이그레이션 — 기동 시 `app_user.secret` 중 **평문(BCrypt 아님)** 을 BCrypt 해시로 승격한다.
* BCrypt 해시는 `$2`로 시작하므로 이미 해시면 건너뛴다(멱등). 시드/신규 사용자가 평문으로 들어와도
* 다음 기동에 자동 해시되어 **평문 저장이 남지 않는다**. 운영에선 사용자 생성 시점에 해시 권장.
*/
@Component
class SecretMigrator(
private val jdbc: JdbcTemplate,
private val encoder: PasswordEncoder,
) : ApplicationRunner {
private val log = LoggerFactory.getLogger(javaClass)
override fun run(args: org.springframework.boot.ApplicationArguments?) {
val plain = jdbc.queryForList(
"SELECT username, secret FROM app_user WHERE secret IS NOT NULL AND secret NOT LIKE '\$2%'",
)
if (plain.isEmpty()) return
var n = 0
for (r in plain) {
val u = r["username"] as String
val raw = r["secret"] as String
jdbc.update("UPDATE app_user SET secret = ? WHERE username = ?", encoder.encode(raw), u)
n++
}
log.warn("자격증명 마이그레이션: 평문 {}건 → BCrypt 해시로 승격", n)
}
}

View File

@@ -0,0 +1,36 @@
spring:
application:
name: gucci
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
username: ${POSTGRES_USER:rtgs}
password: ${POSTGRES_PASSWORD:rtgs}
server:
port: 8095
rtgs:
center-id: ${CENTER_ID:DC1}
gucci:
# 하위 코어(전문송수신) 관문 대상
chanel-base-url: ${CHANEL_URL:http://localhost:8091}
# JWT 서명 비밀키(운영은 외부 시크릿). 프로토타입 기본값.
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789}
jwt-ttl-seconds: 1800
# 유량제어: subject(기관)별 window 내 허용 요청수
rate-limit-per-window: 50
rate-window-seconds: 10
# 재전송 방지: 허용 시각오차(초), nonce 보관 TTL(초)
replay-skew-seconds: 30
replay-nonce-ttl-seconds: 120
# G2 결과 콜백 송부: 폴링 주기(ms), 최대 재시도, 배치 크기
delivery-interval-ms: 3000
delivery-max-attempts: 5
delivery-batch: 50
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics

View File

@@ -0,0 +1,48 @@
package kr.or.bok.rtgs.gucci
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/** Gucci 관문 가드(유량제어·재전송·JWT) 결정론적 검증. */
class GucciGuardsTest {
@Test
fun `rate limiter allows up to limit per window then blocks, resets next window`() {
val rl = RateLimiter(limit = 3, windowSeconds = 10)
val t = 100_000L // 100초 시점(같은 10s 윈도우)
assertTrue(rl.allow("kookmin_s", t))
assertTrue(rl.allow("kookmin_s", t))
assertTrue(rl.allow("kookmin_s", t))
assertTrue(!rl.allow("kookmin_s", t)) // 4번째 초과 → 차단
assertTrue(rl.allow("shinhan_r", t)) // 다른 기관은 독립 버킷
assertTrue(rl.allow("kookmin_s", t + 10_000)) // 다음 윈도우 → 리셋
}
@Test
fun `replay guard rejects reuse, missing, and out-of-window`() {
val rg = ReplayGuard(skewSeconds = 30, nonceTtlSeconds = 120)
val nowMs = 1_000_000_000_000L
val nowSec = nowMs / 1000
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Ok)
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Reject) // 재사용
assertTrue(rg.check(null, nowSec, nowMs) is ReplayGuard.Result.Reject) // nonce 없음
assertTrue(rg.check("n2", nowSec - 60, nowMs) is ReplayGuard.Result.Reject) // 시각오차 초과
assertTrue(rg.check("n3", nowSec, nowMs) is ReplayGuard.Result.Ok)
}
@Test
fun `jwt round-trips, rejects tamper and expiry`() {
val jwt = JwtService(jacksonObjectMapper(), secret = "unit-test-secret-key-0123456789", ttlSeconds = 1800)
val now = 1_000_000_000_000L
val token = jwt.issue(Principal("kookmin_s", "ORG_S", "1001"), now)
val p = jwt.verify(token, now)
assertEquals("kookmin_s", p.subject)
assertEquals("ORG_S", p.role)
assertEquals("1001", p.orgCode)
assertFailsWith<JwtException> { jwt.verify(token + "x", now) } // 변조
assertFailsWith<JwtException> { jwt.verify(token, now + 1_801_000L) } // 만료(ttl 초과)
}
}