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:
20
backend/gucci/build.gradle.kts
Normal file
20
backend/gucci/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-jdbc")
|
||||
implementation("org.springframework.security:spring-security-crypto") // BCrypt(경량, 전체 Security 미도입)
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal file
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ?: "")
|
||||
}
|
||||
}
|
||||
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal file
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
36
backend/gucci/src/main/resources/application.yml
Normal file
36
backend/gucci/src/main/resources/application.yml
Normal 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
|
||||
@@ -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 초과)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user