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
14 changes: 13 additions & 1 deletion api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1451,7 +1451,7 @@ paths:
tags:
- DangerZone
summary: Danger Zone パスワード照合
description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する
description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する。接続元ごとに連続失敗を数え、上限に達している間は429を返す
operationId: verifyDangerZonePassword
requestBody:
required: true
Expand All @@ -1472,6 +1472,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 連続失敗が上限に達したため一時的に照合を受け付けない
headers:
Retry-After:
description: 再試行できるまでの秒数
schema:
type: integer
example: 60
content:
application/json:
schema:
$ref: '#/components/schemas/DangerZoneVerifyResponse'


components:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import info.nukoneko.kidspos.server.controller.dto.response.DangerZonePasswordRe
import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneStatusResponse
import info.nukoneko.kidspos.server.controller.dto.response.DangerZoneVerifyResponse
import info.nukoneko.kidspos.server.service.DangerZonePasswordService
import info.nukoneko.kidspos.server.service.DangerZoneVerifyRateLimiter
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.servlet.http.HttpServletRequest
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
Expand All @@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController
@Tag(name = "DangerZone", description = "危険操作を保護するパスワードAPI")
class DangerZoneApiController(
private val dangerZonePasswordService: DangerZonePasswordService,
private val verifyRateLimiter: DangerZoneVerifyRateLimiter,
) {
@GetMapping("/status")
@Operation(summary = "設定状態取得", description = "Danger Zone パスワードが設定済みかを取得します")
Expand Down Expand Up @@ -55,11 +59,36 @@ class DangerZoneApiController(
@Operation(summary = "パスワード照合", description = "入力されたパスワードがDanger Zone パスワードと一致するかを判定します")
fun verify(
@Valid @RequestBody request: VerifyDangerZonePasswordRequest,
): DangerZoneVerifyResponse {
httpRequest: HttpServletRequest,
): ResponseEntity<DangerZoneVerifyResponse> {
val clientKey = clientKeyOf(httpRequest)
val retryAfter = verifyRateLimiter.retryAfterSeconds(clientKey)
if (retryAfter > 0) {
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, retryAfter.toString())
.body(
DangerZoneVerifyResponse(
valid = false,
configured = dangerZonePasswordService.isConfigured(),
message = "試行回数が多すぎます。${retryAfter}秒後にもう一度お試しください",
),
)
}

val result = dangerZonePasswordService.verify(request.password)
return DangerZoneVerifyResponse(result.valid, result.configured, result.message)
when {
result.valid -> verifyRateLimiter.recordSuccess(clientKey)
result.configured -> verifyRateLimiter.recordFailure(clientKey)
}
return ResponseEntity.ok(DangerZoneVerifyResponse(result.valid, result.configured, result.message))
}

/**
* イントラネット内で直接受けるため、詐称できるX-Forwarded-Forは見ずに接続元アドレスだけを使う。
*/
private fun clientKeyOf(request: HttpServletRequest): String = request.remoteAddr ?: "unknown"

private fun toResponse(result: DangerZonePasswordService.ChangeResult): ResponseEntity<DangerZonePasswordResponse> {
val status = if (result.succeeded) HttpStatus.OK else HttpStatus.BAD_REQUEST
return ResponseEntity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import info.nukoneko.kidspos.server.controller.dto.response.ItemResponse
import info.nukoneko.kidspos.server.domain.exception.InvalidBarcodeException
import info.nukoneko.kidspos.server.domain.exception.ItemNotFoundException
import info.nukoneko.kidspos.server.service.BarcodePdfService
import info.nukoneko.kidspos.server.service.BarcodeService
import info.nukoneko.kidspos.server.service.ItemService
import info.nukoneko.kidspos.server.service.ValidationService
import info.nukoneko.kidspos.server.service.mapper.ItemMapper
Expand Down Expand Up @@ -42,7 +41,6 @@ class ItemApiController(
private val itemService: ItemService,
private val itemMapper: ItemMapper,
private val validationService: ValidationService,
private val barcodeService: BarcodeService,
private val barcodePdfService: BarcodePdfService,
) {
private val logger = LoggerFactory.getLogger(ItemApiController::class.java)
Expand Down Expand Up @@ -279,7 +277,7 @@ class ItemApiController(
throw ItemNotFoundException()
}

val pdfBytes = barcodeService.generateBarcodePdf(items, showBorders)
val pdfBytes = barcodePdfService.getSelectedItemsPdf(items, showBorders)

logger.info("Selected barcode PDF generated successfully with {} items", items.size)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package info.nukoneko.kidspos.server.service

import info.nukoneko.kidspos.server.entity.ItemEntity
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import java.security.MessageDigest
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.thread
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException

/**
* 全商品のバーコードPDFを保持するサービス
* バーコードPDFを保持するサービス
*
* 商品一覧が変わらない限り生成済みのバイト列を返す。
* 起動直後にバックグラウンドで生成しておくことで
* 起動直後と商品変更後にバックグラウンドで生成しておくことで
* 端末側のリクエストがタイムアウトする事態を避ける。
*/
@Service
Expand All @@ -27,6 +33,20 @@ class BarcodePdfService(
private val logger = LoggerFactory.getLogger(BarcodePdfService::class.java)
private val cache = ConcurrentHashMap<Boolean, CachedPdf>()
private val locks = ConcurrentHashMap<Boolean, Any>()
private val selectedLock = Any()

private val selectedCache: MutableMap<String, ByteArray> =
Collections.synchronizedMap(
object : LinkedHashMap<String, ByteArray>(SELECTED_CACHE_CAPACITY, LOAD_FACTOR, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, ByteArray>): Boolean = size > SELECTED_CACHE_CAPACITY
},
)

private val warmUpExecutor: ExecutorService by lazy {
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "barcode-pdf-warmup").apply { isDaemon = true }
}
}

fun getAllItemsPdf(showBorders: Boolean = false): ByteArray {
val items = itemService.findAll()
Expand Down Expand Up @@ -59,13 +79,49 @@ class BarcodePdfService(
}
}

/**
* 選択された商品のPDFを返す。
*
* 署名をそのままキャッシュキーにしているため、商品名や価格が変われば別のキーになり
* 古いエントリはLRUで押し出される。生成を直列化して、同じ選択の同時要求で
* 非力な端末のCPUを何度も使わないようにする。
*/
fun getSelectedItemsPdf(
items: List<ItemEntity>,
showBorders: Boolean,
): ByteArray {
val signature = signatureOf(items, showBorders)

selectedCache[signature]?.let { cached ->
logger.debug("Reusing cached selected barcode PDF (showBorders={})", showBorders)
return cached
}

return synchronized(selectedLock) {
selectedCache[signature]?.let { return@synchronized it }

val startedAt = System.currentTimeMillis()
val bytes = barcodeService.generateBarcodePdf(items, showBorders)
selectedCache[signature] = bytes
logger.info(
"Generated selected barcode PDF for {} items in {} ms (showBorders={})",
items.size,
System.currentTimeMillis() - startedAt,
showBorders,
)
bytes
}
}

fun isCached(showBorders: Boolean = false): Boolean = cache.containsKey(showBorders)

fun warmUp() {
try {
getAllItemsPdf(false)
} catch (e: Exception) {
logger.warn("Failed to warm up barcode PDF cache: {}", e.message)
BORDER_VARIANTS.forEach { showBorders ->
try {
getAllItemsPdf(showBorders)
} catch (e: Exception) {
logger.warn("Failed to warm up barcode PDF cache (showBorders={}): {}", showBorders, e.message)
}
}
}

Expand All @@ -75,8 +131,25 @@ class BarcodePdfService(
logger.debug("Barcode PDF warm-up on startup is disabled")
return
}
thread(start = true, isDaemon = true, name = "barcode-pdf-warmup") {
warmUp()
scheduleWarmUp()
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
fun onItemsChanged(event: ItemsChangedEvent) {
logger.debug("Rebuilding barcode PDF cache after item change (itemId={})", event.itemId)
scheduleWarmUp()
}

@PreDestroy
fun shutdown() {
warmUpExecutor.shutdownNow()
}

private fun scheduleWarmUp() {
try {
warmUpExecutor.execute { warmUp() }
} catch (e: RejectedExecutionException) {
logger.debug("Barcode PDF warm-up was not scheduled: {}", e.message)
}
}

Expand All @@ -96,4 +169,10 @@ class BarcodePdfService(
val signature: String,
val bytes: ByteArray,
)

private companion object {
val BORDER_VARIANTS = listOf(false, true)
const val SELECTED_CACHE_CAPACITY = 8
const val LOAD_FACTOR = 0.75f
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package info.nukoneko.kidspos.server.service

import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap

internal const val DEFAULT_VERIFY_MAX_FAILURES = 5
internal const val DEFAULT_VERIFY_BLOCK_SECONDS = 60L

/**
* Danger Zone パスワード照合の連打を抑えるレートリミッター
*
* 照合は PBKDF2 を10万回反復するため、総当たりは推測される危険に加えて
* Raspberry Pi の CPU を占有し他の会計処理まで巻き添えにする。
* クライアントごとに失敗回数を数え、上限に達した間は照合そのものを行わせない。
*/
@Component
class DangerZoneVerifyRateLimiter(
@Value("\${app.danger-zone.verify.max-failures:$DEFAULT_VERIFY_MAX_FAILURES}")
private val maxFailures: Int = DEFAULT_VERIFY_MAX_FAILURES,
@Value("\${app.danger-zone.verify.block-seconds:$DEFAULT_VERIFY_BLOCK_SECONDS}")
private val blockSeconds: Long = DEFAULT_VERIFY_BLOCK_SECONDS,
) {
private val logger = LoggerFactory.getLogger(DangerZoneVerifyRateLimiter::class.java)
private val attempts = ConcurrentHashMap<String, Attempt>()

@Volatile
internal var timeSource: () -> Long = System::currentTimeMillis

/**
* ブロック中なら解除までの残り秒数を、そうでなければ0を返す。
*/
fun retryAfterSeconds(clientKey: String): Long {
val attempt = attempts[clientKey] ?: return 0
val remaining = attempt.blockedUntil - timeSource()
if (remaining <= 0) {
return 0
}
return (remaining + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND
}

fun recordFailure(clientKey: String) {
val now = timeSource()
val blockMillis = blockSeconds * MILLIS_PER_SECOND
purgeExpired(now, blockMillis)

val updated =
attempts.compute(clientKey) { _, current ->
val continued = current != null && now - current.lastFailureAt <= blockMillis
val failures = if (continued) current.failures + 1 else 1
if (failures >= maxFailures) {
Attempt(failures = 0, blockedUntil = now + blockMillis, lastFailureAt = now)
} else {
Attempt(failures = failures, blockedUntil = 0, lastFailureAt = now)
}
}

if (updated != null && updated.blockedUntil > now) {
logger.warn("Danger zone verification blocked for {} seconds (client={})", blockSeconds, clientKey)
}
}

fun recordSuccess(clientKey: String) {
attempts.remove(clientKey)
}

internal fun trackedClientCount(): Int = attempts.size

/**
* 失敗した端末の分だけエントリが残るため、上限を超えたら期限切れの分を捨てる。
*/
private fun purgeExpired(
now: Long,
blockMillis: Long,
) {
if (attempts.size < MAX_TRACKED_CLIENTS) {
return
}
attempts.entries.removeIf { (_, attempt) ->
attempt.blockedUntil <= now && now - attempt.lastFailureAt > blockMillis
}
}

private data class Attempt(
val failures: Int,
val blockedUntil: Long,
val lastFailureAt: Long,
)

private companion object {
const val MILLIS_PER_SECOND = 1_000L
const val MAX_TRACKED_CLIENTS = 1_000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.cache.annotation.Caching
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand Down Expand Up @@ -39,12 +40,14 @@ import org.springframework.transaction.annotation.Transactional
* @constructor Creates ItemService with required dependencies
* @param repository Repository for item data access
* @param idGenerationService Service for generating unique item IDs
* @param eventPublisher Publisher used to notify listeners about item changes
*/
@Service
@Transactional
class ItemService(
private val repository: ItemRepository,
private val idGenerationService: IdGenerationService,
private val eventPublisher: ApplicationEventPublisher,
) {
private val logger = LoggerFactory.getLogger(ItemService::class.java)

Expand Down Expand Up @@ -95,6 +98,7 @@ class ItemService(
val item = ItemEntity(generatedId, finalBarcode, itemBean.name, itemBean.price)
val savedItem = repository.save(item)
logger.info("Item created successfully with ID: {}, barcode: {}", savedItem.id, savedItem.barcode)
eventPublisher.publishEvent(ItemsChangedEvent(savedItem.id))
return savedItem
}

Expand All @@ -110,6 +114,7 @@ class ItemService(
if (item != null) {
repository.delete(item)
logger.info("Item deleted successfully with ID: {}", id)
eventPublisher.publishEvent(ItemsChangedEvent(id))
}
}
}
Loading
Loading