Add secure administrator operations console
Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
@@ -7,6 +7,27 @@ import com.osglab.account.common.security.SessionJwt
|
||||
import com.osglab.account.common.security.installSessionAuthentication
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
|
||||
import com.osglab.account.features.admin.routes.adminApiRoutes
|
||||
import com.osglab.account.features.admin.routes.adminWebRoutes
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpVerifier
|
||||
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
|
||||
import com.osglab.account.features.admin.security.HmacTotpVerifier
|
||||
import com.osglab.account.features.admin.services.AdminAuthService
|
||||
import com.osglab.account.features.admin.services.AdminAuditService
|
||||
import com.osglab.account.features.admin.services.AdminBootstrapConfig
|
||||
import com.osglab.account.features.admin.services.AdminBootstrapService
|
||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.account.AccountRepository
|
||||
import com.osglab.account.features.account.AccountReauthenticator
|
||||
import com.osglab.account.features.account.AccountService
|
||||
@@ -120,6 +141,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
import org.koin.ktor.ext.getKoin
|
||||
@@ -180,6 +202,12 @@ fun Application.module() {
|
||||
register(PUBLIC_RATE_LIMIT) {
|
||||
rateLimiter(limit = 120, refillPeriod = 1.minutes)
|
||||
}
|
||||
register(ADMIN_AUTH_RATE_LIMIT) {
|
||||
rateLimiter(limit = 5, refillPeriod = 1.minutes)
|
||||
}
|
||||
register(ADMIN_API_RATE_LIMIT) {
|
||||
rateLimiter(limit = 60, refillPeriod = 1.minutes)
|
||||
}
|
||||
}
|
||||
installApiStatusPages()
|
||||
|
||||
@@ -191,6 +219,19 @@ fun Application.module() {
|
||||
val koin = getKoin()
|
||||
// Fail startup before accepting traffic if migrations or database connectivity fail.
|
||||
koin.get<DatabaseFactory>().database
|
||||
if (appConfig.admin.bootstrapEnabled) {
|
||||
runBlocking {
|
||||
koin.get<AdminBootstrapService>().initialize(
|
||||
AdminBootstrapConfig(
|
||||
enabled = true,
|
||||
operatorId = appConfig.admin.bootstrapOperatorId,
|
||||
username = appConfig.admin.bootstrapUsername,
|
||||
passwordHash = appConfig.admin.bootstrapPasswordHash,
|
||||
totpSecretBase32 = appConfig.admin.bootstrapTotpSecretBase32,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
|
||||
installSessionAuthentication(sessionAuthenticator::authenticate)
|
||||
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
|
||||
@@ -220,6 +261,15 @@ fun Application.module() {
|
||||
} catch (_: Exception) {
|
||||
// Durable settlement state is retried without logging provider data.
|
||||
}
|
||||
if (appConfig.admin.enabled) {
|
||||
try {
|
||||
koin.get<AdminSessionService>().cleanupInactive()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (_: Exception) {
|
||||
// Expired sessions are retried in bounded batches on the next cycle.
|
||||
}
|
||||
}
|
||||
delay(60_000)
|
||||
}
|
||||
}
|
||||
@@ -253,6 +303,21 @@ fun Application.module() {
|
||||
configureInviteWebRoutes(koin.get(), koin.get())
|
||||
integrityRoutes(koin.get())
|
||||
}
|
||||
if (appConfig.admin.enabled) {
|
||||
adminWebRoutes()
|
||||
rateLimit(ADMIN_API_RATE_LIMIT) {
|
||||
adminApiRoutes(
|
||||
config = appConfig,
|
||||
authService = koin.get(),
|
||||
sessionService = koin.get(),
|
||||
statsService = koin.get(),
|
||||
usersService = koin.get(),
|
||||
grantService = koin.get(),
|
||||
operatorService = koin.get(),
|
||||
auditService = koin.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +361,33 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single { SessionJwt(config.session) }
|
||||
single { FieldEncryptor(config.encryption.key) }
|
||||
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
|
||||
single<AdminRepository> { ExposedAdminRepository(get()) }
|
||||
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
|
||||
single<AdminTotpVerifier> { HmacTotpVerifier() }
|
||||
single {
|
||||
val dummyPassword = "invalid-admin-password-constant-work".toCharArray()
|
||||
try {
|
||||
AdminAuthService(
|
||||
repository = get(),
|
||||
passwordHasher = get(),
|
||||
dummyPasswordHash = get<AdminPasswordHasher>().hash(dummyPassword),
|
||||
totpVerifier = get(),
|
||||
fieldEncryptor = get(),
|
||||
sessionTtl = Duration.ofHours(config.admin.sessionHours),
|
||||
)
|
||||
} finally {
|
||||
dummyPassword.fill('\u0000')
|
||||
}
|
||||
}
|
||||
single { AdminSessionService(get()) }
|
||||
single { AdminBootstrapService(get(), get()) }
|
||||
single { AdminOperatorService(get(), get(), get()) }
|
||||
single { AdminAuditService(get()) }
|
||||
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
||||
single { AdminStatsService(get()) }
|
||||
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
||||
single { AdminUsersService(get()) }
|
||||
single { AdminGrantService(get()) }
|
||||
single<AppleJwksProvider> {
|
||||
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
|
||||
}
|
||||
@@ -513,3 +605,5 @@ private val AUTH_RATE_LIMIT = RateLimitName("auth")
|
||||
private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
|
||||
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
|
||||
private val PUBLIC_RATE_LIMIT = RateLimitName("public")
|
||||
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
|
||||
private val ADMIN_API_RATE_LIMIT = RateLimitName("admin-api")
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.osglab.account.config
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import java.net.URI
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
data class AppConfig(
|
||||
val environment: Environment,
|
||||
@@ -17,6 +18,7 @@ data class AppConfig(
|
||||
val credits: CreditsConfig,
|
||||
val providers: ProvidersConfig,
|
||||
val integrity: IntegrityConfig,
|
||||
val admin: AdminConfig = AdminConfig(),
|
||||
) {
|
||||
val isProduction: Boolean = environment == Environment.PRODUCTION
|
||||
|
||||
@@ -119,6 +121,41 @@ data class AppConfig(
|
||||
300,
|
||||
),
|
||||
)
|
||||
val adminEnabled = config.booleanOrDefault("app.admin.enabled", false)
|
||||
val adminBootstrapEnabled = config.booleanOrDefault(
|
||||
"app.admin.bootstrapEnabled",
|
||||
false,
|
||||
)
|
||||
require(!adminBootstrapEnabled || adminEnabled) {
|
||||
"app.admin.bootstrapEnabled requires app.admin.enabled"
|
||||
}
|
||||
val admin = AdminConfig(
|
||||
enabled = adminEnabled,
|
||||
bootstrapEnabled = adminBootstrapEnabled,
|
||||
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
|
||||
?.let {
|
||||
runCatching { UUID.fromString(it) }.getOrElse { cause ->
|
||||
throw ConfigValidationException(
|
||||
"app.admin.bootstrapOperatorId must be a UUID",
|
||||
cause,
|
||||
)
|
||||
}
|
||||
},
|
||||
bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"),
|
||||
bootstrapPasswordHash = config.optionalLiteralSecret(
|
||||
"app.admin.bootstrapPasswordHash",
|
||||
production && adminBootstrapEnabled,
|
||||
),
|
||||
bootstrapTotpSecretBase32 = config.optionalSecret(
|
||||
"app.admin.bootstrapTotpSecretBase32",
|
||||
production && adminBootstrapEnabled,
|
||||
),
|
||||
sessionHours = config.positiveLong("app.admin.sessionHours", 8),
|
||||
maximumManualGrant = config.positiveLong(
|
||||
"app.admin.maximumManualGrant",
|
||||
100_000,
|
||||
),
|
||||
)
|
||||
|
||||
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
|
||||
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
|
||||
@@ -199,6 +236,26 @@ data class AppConfig(
|
||||
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
|
||||
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
|
||||
}
|
||||
require(admin.sessionHours in 1..24) {
|
||||
"app.admin.sessionHours must be between 1 and 24"
|
||||
}
|
||||
require(admin.maximumManualGrant in 1..100_000_000) {
|
||||
"app.admin.maximumManualGrant must be between 1 and 100000000"
|
||||
}
|
||||
if (admin.bootstrapEnabled) {
|
||||
requireNotNull(admin.bootstrapOperatorId) {
|
||||
"app.admin.bootstrapOperatorId is required when admin bootstrap is enabled"
|
||||
}
|
||||
require(!admin.bootstrapUsername.isNullOrBlank()) {
|
||||
"app.admin.bootstrapUsername is required when admin bootstrap is enabled"
|
||||
}
|
||||
require(!admin.bootstrapPasswordHash.isNullOrBlank()) {
|
||||
"app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled"
|
||||
}
|
||||
require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) {
|
||||
"app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled"
|
||||
}
|
||||
}
|
||||
if (production) {
|
||||
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
|
||||
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
|
||||
@@ -239,6 +296,7 @@ data class AppConfig(
|
||||
credits = credits,
|
||||
providers = providers,
|
||||
integrity = integrity,
|
||||
admin = admin,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -335,6 +393,17 @@ data class IntegrityConfig(
|
||||
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
||||
)
|
||||
|
||||
data class AdminConfig(
|
||||
val enabled: Boolean = false,
|
||||
val bootstrapEnabled: Boolean = false,
|
||||
val bootstrapOperatorId: UUID? = null,
|
||||
val bootstrapUsername: String? = null,
|
||||
val bootstrapPasswordHash: String? = null,
|
||||
val bootstrapTotpSecretBase32: String? = null,
|
||||
val sessionHours: Long = 8,
|
||||
val maximumManualGrant: Long = 100_000,
|
||||
)
|
||||
|
||||
enum class IntegrityPolicy {
|
||||
MONITOR,
|
||||
ENFORCE;
|
||||
@@ -393,6 +462,19 @@ private fun ApplicationConfig.optionalSecret(path: String, production: Boolean):
|
||||
return value?.takeUnless(String::isPlaceholder)
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? {
|
||||
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
|
||||
val placeholder = value?.let {
|
||||
it.contains("replace-with", ignoreCase = true) ||
|
||||
it.contains("change-me", ignoreCase = true) ||
|
||||
it.contains("\${")
|
||||
} == true
|
||||
if (production && (value == null || placeholder)) {
|
||||
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
|
||||
}
|
||||
return value?.takeUnless { placeholder }
|
||||
}
|
||||
|
||||
private fun String.isPlaceholder(): Boolean =
|
||||
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
|
||||
|
||||
@@ -419,6 +501,15 @@ private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean =
|
||||
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
|
||||
when (it.lowercase()) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> throw ConfigValidationException("$path must be true or false")
|
||||
}
|
||||
} ?: default
|
||||
|
||||
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
|
||||
val encoded = secret(path, production)
|
||||
return try {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.osglab.account.features.admin.grants.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
data class ManualGrantCommand(
|
||||
val operatorId: UUID,
|
||||
val userId: UUID,
|
||||
val credits: Long,
|
||||
val reason: String,
|
||||
val requestId: String? = null,
|
||||
val idempotencyKey: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminCreditGrantDto(
|
||||
val id: String,
|
||||
val operatorId: String,
|
||||
val userId: String,
|
||||
val credits: Long,
|
||||
val reason: String,
|
||||
val ledgerEntryId: String,
|
||||
val auditLogId: String,
|
||||
val balanceAfter: Long,
|
||||
val createdAt: String,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.osglab.account.features.admin.grants.repositories
|
||||
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
|
||||
import com.osglab.account.features.credits.domain.ManualCreditGrant
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import java.util.UUID
|
||||
|
||||
interface AdminCreditGrantRepository {
|
||||
fun findByIdempotencyKey(idempotencyKey: String): ManualCreditGrant?
|
||||
|
||||
fun insertAudit(event: NewAdminAuditEvent)
|
||||
fun insert(grant: ManualCreditGrant)
|
||||
}
|
||||
|
||||
internal object AdminCreditGrantsTable : Table("admin_credit_grants") {
|
||||
val id = varchar("id", 36)
|
||||
val operatorId = varchar("operator_id", 36)
|
||||
val accountId = varchar("account_id", 36)
|
||||
val amount = long("amount")
|
||||
val reason = varchar("reason", 500)
|
||||
val idempotencyKey = varchar("idempotency_key", 128)
|
||||
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||
val auditLogId = varchar("audit_log_id", 36)
|
||||
val createdAt = timestamp("created_at")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object ExposedAdminCreditGrantRepository : AdminCreditGrantRepository {
|
||||
override fun findByIdempotencyKey(
|
||||
idempotencyKey: String,
|
||||
): ManualCreditGrant? =
|
||||
AdminCreditGrantsTable
|
||||
.selectAll()
|
||||
.where { AdminCreditGrantsTable.idempotencyKey eq idempotencyKey }
|
||||
.singleOrNull()
|
||||
?.toManualCreditGrant()
|
||||
|
||||
override fun insertAudit(event: NewAdminAuditEvent) {
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = event.id.toString()
|
||||
it[actorOperatorId] = event.actorOperatorId?.toString()
|
||||
it[action] = event.action.name
|
||||
it[outcome] = event.outcome.name
|
||||
it[targetType] = event.targetType
|
||||
it[targetId] = event.targetId
|
||||
it[requestId] = event.requestId
|
||||
it[occurredAt] = event.occurredAt
|
||||
}
|
||||
}
|
||||
|
||||
override fun insert(grant: ManualCreditGrant) {
|
||||
AdminCreditGrantsTable.insert {
|
||||
it[id] = grant.id.toString()
|
||||
it[operatorId] = grant.operatorId.toString()
|
||||
it[accountId] = grant.userId.toString()
|
||||
it[amount] = grant.amount
|
||||
it[reason] = grant.reason
|
||||
it[idempotencyKey] = grant.idempotencyKey
|
||||
it[ledgerEntryId] = grant.ledgerEntryId.toString()
|
||||
it[auditLogId] = grant.auditLogId.toString()
|
||||
it[createdAt] = grant.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultRow.toManualCreditGrant() = ManualCreditGrant(
|
||||
id = UUID.fromString(this[AdminCreditGrantsTable.id]),
|
||||
operatorId = UUID.fromString(this[AdminCreditGrantsTable.operatorId]),
|
||||
userId = UUID.fromString(this[AdminCreditGrantsTable.accountId]),
|
||||
amount = this[AdminCreditGrantsTable.amount],
|
||||
reason = this[AdminCreditGrantsTable.reason],
|
||||
idempotencyKey = this[AdminCreditGrantsTable.idempotencyKey],
|
||||
ledgerEntryId = UUID.fromString(this[AdminCreditGrantsTable.ledgerEntryId]),
|
||||
auditLogId = UUID.fromString(this[AdminCreditGrantsTable.auditLogId]),
|
||||
createdAt = this[AdminCreditGrantsTable.createdAt],
|
||||
)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.osglab.account.features.admin.grants.services
|
||||
|
||||
import com.osglab.account.features.admin.grants.models.AdminCreditGrantDto
|
||||
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
||||
import com.osglab.account.features.credits.services.CreditOperations
|
||||
|
||||
class AdminGrantService(
|
||||
private val credits: CreditOperations,
|
||||
) {
|
||||
suspend fun grant(command: ManualGrantCommand): AdminCreditGrantDto {
|
||||
val result = credits.grantManual(
|
||||
operatorId = command.operatorId,
|
||||
userId = command.userId,
|
||||
credits = command.credits,
|
||||
reason = command.reason,
|
||||
requestId = command.requestId,
|
||||
idempotencyKey = command.idempotencyKey,
|
||||
)
|
||||
return AdminCreditGrantDto(
|
||||
id = result.grant.id.toString(),
|
||||
operatorId = result.grant.operatorId.toString(),
|
||||
userId = result.grant.userId.toString(),
|
||||
credits = result.grant.amount,
|
||||
reason = result.grant.reason,
|
||||
ledgerEntryId = result.grant.ledgerEntryId.toString(),
|
||||
auditLogId = result.grant.auditLogId.toString(),
|
||||
balanceAfter = result.balanceAfter,
|
||||
createdAt = result.grant.createdAt.toString(),
|
||||
replayed = result.replayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.osglab.account.features.admin.models
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class AdminRole {
|
||||
SUPER_ADMIN,
|
||||
SUPPORT,
|
||||
ANALYST,
|
||||
}
|
||||
|
||||
data class AdminOperatorRecord(
|
||||
val id: UUID,
|
||||
val normalizedUsername: String,
|
||||
val role: AdminRole,
|
||||
val lockState: AdminLockState,
|
||||
val disabledAt: Instant?,
|
||||
val lastLoginAt: Instant?,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
data class AdminOperatorCursor(
|
||||
val createdAt: Instant,
|
||||
val id: UUID,
|
||||
)
|
||||
|
||||
data class NewAdminOperator(
|
||||
val id: UUID,
|
||||
val normalizedUsername: String,
|
||||
val passwordHash: String,
|
||||
val encryptedTotpSecret: String,
|
||||
val role: AdminRole,
|
||||
val createdAt: Instant,
|
||||
) {
|
||||
init {
|
||||
require(normalizedUsername.isNotBlank())
|
||||
require(passwordHash.isNotBlank())
|
||||
require(encryptedTotpSecret.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminLockState(
|
||||
val failedLoginCount: Int,
|
||||
val lockedUntil: Instant?,
|
||||
) {
|
||||
init {
|
||||
require(failedLoginCount >= 0)
|
||||
}
|
||||
|
||||
fun isLockedAt(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication-only record. Its string representation deliberately excludes
|
||||
* password and TOTP material.
|
||||
*/
|
||||
class AdminOperatorAuthRecord(
|
||||
val id: UUID,
|
||||
val normalizedUsername: String,
|
||||
val passwordHash: String,
|
||||
val encryptedTotpSecret: String,
|
||||
val role: AdminRole,
|
||||
val lockState: AdminLockState,
|
||||
val disabledAt: Instant?,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AdminOperatorAuthRecord(id=$id, normalizedUsername=$normalizedUsername, " +
|
||||
"role=$role, lockState=$lockState, disabled=${disabledAt != null})"
|
||||
}
|
||||
|
||||
data class NewAdminSession(
|
||||
val id: UUID,
|
||||
val operatorId: UUID,
|
||||
val tokenHash: String,
|
||||
val csrfTokenHash: String,
|
||||
val createdAt: Instant,
|
||||
val expiresAt: Instant,
|
||||
) {
|
||||
init {
|
||||
require(expiresAt.isAfter(createdAt))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository session record. Token digests are omitted from logs and errors.
|
||||
*/
|
||||
class AdminSessionRecord(
|
||||
val id: UUID,
|
||||
val operatorId: UUID,
|
||||
val normalizedUsername: String,
|
||||
val role: AdminRole,
|
||||
val csrfTokenHash: String,
|
||||
val expiresAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AdminSessionRecord(id=$id, operatorId=$operatorId, " +
|
||||
"normalizedUsername=$normalizedUsername, role=$role, expiresAt=$expiresAt)"
|
||||
}
|
||||
|
||||
data class AdminPrincipal(
|
||||
val operatorId: UUID,
|
||||
val sessionId: UUID,
|
||||
val normalizedUsername: String,
|
||||
val role: AdminRole,
|
||||
)
|
||||
|
||||
enum class AdminAuditAction {
|
||||
LOGIN_SUCCEEDED,
|
||||
LOGIN_FAILED,
|
||||
SESSION_REVOKED,
|
||||
OPERATOR_CREATED,
|
||||
OPERATOR_ENABLED,
|
||||
OPERATOR_DISABLED,
|
||||
OPERATOR_UNLOCKED,
|
||||
OPERATOR_CREDENTIALS_RESET,
|
||||
OPERATOR_SESSIONS_REVOKED,
|
||||
MANUAL_CREDIT_GRANTED,
|
||||
}
|
||||
|
||||
enum class AdminAuditOutcome {
|
||||
SUCCESS,
|
||||
DENIED,
|
||||
}
|
||||
|
||||
data class NewAdminAuditEvent(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val actorOperatorId: UUID?,
|
||||
val action: AdminAuditAction,
|
||||
val outcome: AdminAuditOutcome,
|
||||
val targetType: String? = null,
|
||||
val targetId: String? = null,
|
||||
val requestId: String? = null,
|
||||
val occurredAt: Instant,
|
||||
) {
|
||||
init {
|
||||
require((targetType == null) == (targetId == null))
|
||||
require(targetType == null || targetType.isNotBlank())
|
||||
require(targetId == null || targetId.isNotBlank())
|
||||
require(requestId == null || requestId.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminAuditRecord(
|
||||
val id: UUID,
|
||||
val actorOperatorId: UUID?,
|
||||
val action: AdminAuditAction,
|
||||
val outcome: AdminAuditOutcome,
|
||||
val targetType: String?,
|
||||
val targetId: String?,
|
||||
val requestId: String?,
|
||||
val occurredAt: Instant,
|
||||
)
|
||||
|
||||
data class AdminAuditCursor(
|
||||
val occurredAt: Instant,
|
||||
val id: UUID,
|
||||
)
|
||||
|
||||
/**
|
||||
* Raw credentials are returned once and must only be transported in secure,
|
||||
* HttpOnly/Secure cookies. They are never persisted by this service.
|
||||
*/
|
||||
class AdminSessionCredentials(
|
||||
val sessionToken: String,
|
||||
val csrfToken: String,
|
||||
val expiresAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AdminSessionCredentials(sessionToken=[REDACTED], csrfToken=[REDACTED], expiresAt=$expiresAt)"
|
||||
}
|
||||
|
||||
sealed interface AdminLoginResult {
|
||||
data class Authenticated(
|
||||
val principal: AdminPrincipal,
|
||||
val credentials: AdminSessionCredentials,
|
||||
) : AdminLoginResult
|
||||
|
||||
data class Locked(val retryAt: Instant) : AdminLoginResult
|
||||
|
||||
data object InvalidCredentials : AdminLoginResult
|
||||
}
|
||||
|
||||
enum class AdminOperatorMutationResult {
|
||||
SUCCESS,
|
||||
NOT_FOUND,
|
||||
USERNAME_CONFLICT,
|
||||
LAST_SUPER_ADMIN,
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package com.osglab.account.features.admin.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNotNull
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.lessEq
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
internal object AdminOperatorsTable : Table("admin_operators") {
|
||||
val id = varchar("id", 36)
|
||||
val username = varchar("username", 64).uniqueIndex()
|
||||
val passwordHash = varchar("password_hash", 255)
|
||||
val encryptedTotpSecret = text("encrypted_totp_secret")
|
||||
val role = varchar("role", 32)
|
||||
val failedLoginCount = integer("failed_login_count")
|
||||
val lockedUntil = timestamp("locked_until").nullable()
|
||||
val lastTotpCounter = long("last_totp_counter").nullable()
|
||||
val lastLoginAt = timestamp("last_login_at").nullable()
|
||||
val disabledAt = timestamp("disabled_at").nullable()
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object AdminSessionsTable : Table("admin_sessions") {
|
||||
val id = varchar("id", 36)
|
||||
val operatorId = varchar("operator_id", 36).index()
|
||||
val tokenHash = char("token_hash", 64).uniqueIndex()
|
||||
val csrfTokenHash = char("csrf_token_hash", 64)
|
||||
val createdAt = timestamp("created_at")
|
||||
val expiresAt = timestamp("expires_at").index()
|
||||
val revokedAt = timestamp("revoked_at").nullable()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object AdminAuditLogTable : Table("admin_audit_log") {
|
||||
val id = varchar("id", 36)
|
||||
val actorOperatorId = varchar("actor_operator_id", 36).nullable().index()
|
||||
val action = varchar("action", 64)
|
||||
val outcome = varchar("outcome", 32)
|
||||
val targetType = varchar("target_type", 64).nullable()
|
||||
val targetId = varchar("target_id", 128).nullable()
|
||||
val requestId = varchar("request_id", 128).nullable()
|
||||
val occurredAt = timestamp("occurred_at").index()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
interface AdminRepository {
|
||||
suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean
|
||||
suspend fun createOperator(
|
||||
operator: NewAdminOperator,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult
|
||||
suspend fun listOperators(): List<AdminOperatorRecord>
|
||||
suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor? = null,
|
||||
): List<AdminOperatorRecord>
|
||||
suspend fun countActiveSessions(now: Instant): Long
|
||||
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
|
||||
suspend fun setOperatorEnabled(
|
||||
operatorId: UUID,
|
||||
enabled: Boolean,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult
|
||||
suspend fun unlockOperator(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult
|
||||
suspend fun resetOperatorCredentials(
|
||||
operatorId: UUID,
|
||||
passwordHash: String,
|
||||
encryptedTotpSecret: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult
|
||||
suspend fun revokeOperatorSessions(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult
|
||||
|
||||
suspend fun findOperatorForAuthentication(normalizedUsername: String): AdminOperatorAuthRecord?
|
||||
|
||||
/**
|
||||
* Executes the transformation while holding the operator row lock.
|
||||
*/
|
||||
suspend fun updateLockState(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
transform: (AdminLockState) -> AdminLockState,
|
||||
): AdminLockState?
|
||||
|
||||
/**
|
||||
* Atomically consumes a newer TOTP counter, clears the lock state, and
|
||||
* creates the session. A null result means the operator became unavailable
|
||||
* or the counter was already consumed.
|
||||
*/
|
||||
suspend fun createSessionIfTotpCounterFresh(
|
||||
session: NewAdminSession,
|
||||
totpCounter: Long,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord?
|
||||
|
||||
suspend fun findActiveSessionByTokenHash(tokenHash: String, now: Instant): AdminSessionRecord?
|
||||
suspend fun revokeSessionByTokenHash(
|
||||
tokenHash: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord?
|
||||
suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int
|
||||
suspend fun appendAudit(event: NewAdminAuditEvent)
|
||||
suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor? = null,
|
||||
): List<AdminAuditRecord>
|
||||
}
|
||||
|
||||
class ExposedAdminRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : AdminRepository {
|
||||
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
|
||||
databaseFactory.query {
|
||||
insertOperatorIgnoringConflict(operator)
|
||||
}
|
||||
|
||||
override suspend fun createOperator(
|
||||
operator: NewAdminOperator,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = databaseFactory.query {
|
||||
val result = if (insertOperatorIgnoringConflict(operator)) {
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
} else {
|
||||
AdminOperatorMutationResult.USERNAME_CONFLICT
|
||||
}
|
||||
insertAudit(auditEvent.withResult(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun listOperators(): List<AdminOperatorRecord> =
|
||||
databaseFactory.query {
|
||||
AdminOperatorsTable.selectAll()
|
||||
.orderBy(
|
||||
AdminOperatorsTable.createdAt to SortOrder.ASC,
|
||||
AdminOperatorsTable.id to SortOrder.ASC,
|
||||
)
|
||||
.map(ResultRow::toOperatorRecord)
|
||||
}
|
||||
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
): List<AdminOperatorRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminOperatorsTable.selectAll()
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
(AdminOperatorsTable.createdAt greater before.createdAt) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq before.createdAt) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
query
|
||||
.orderBy(
|
||||
AdminOperatorsTable.createdAt to SortOrder.ASC,
|
||||
AdminOperatorsTable.id to SortOrder.ASC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toOperatorRecord)
|
||||
}
|
||||
|
||||
override suspend fun countActiveSessions(now: Instant): Long =
|
||||
databaseFactory.query {
|
||||
AdminSessionsTable.selectAll()
|
||||
.where {
|
||||
AdminSessionsTable.revokedAt.isNull() and
|
||||
(AdminSessionsTable.expiresAt greater now)
|
||||
}
|
||||
.count()
|
||||
}
|
||||
|
||||
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? =
|
||||
databaseFactory.query {
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq operatorId.toString() }
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?.toOperatorRecord()
|
||||
}
|
||||
|
||||
override suspend fun setOperatorEnabled(
|
||||
operatorId: UUID,
|
||||
enabled: Boolean,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = databaseFactory.query {
|
||||
if (!enabled) {
|
||||
// Lock every enabled super-administrator in deterministic order so
|
||||
// concurrent disables cannot both observe themselves as non-last.
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where {
|
||||
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
|
||||
AdminOperatorsTable.disabledAt.isNull()
|
||||
}
|
||||
.orderBy(AdminOperatorsTable.id to SortOrder.ASC)
|
||||
.forUpdate()
|
||||
.toList()
|
||||
}
|
||||
val row = AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq operatorId.toString() }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
val result = when {
|
||||
row == null -> AdminOperatorMutationResult.NOT_FOUND
|
||||
!enabled &&
|
||||
row[AdminOperatorsTable.disabledAt] == null &&
|
||||
row[AdminOperatorsTable.role] == AdminRole.SUPER_ADMIN.name &&
|
||||
enabledSuperAdministratorCount() <= 1 -> AdminOperatorMutationResult.LAST_SUPER_ADMIN
|
||||
else -> {
|
||||
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
|
||||
it[disabledAt] = if (enabled) null else now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
if (!enabled) revokeActiveSessions(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
}
|
||||
insertAudit(auditEvent.withResult(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun unlockOperator(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = databaseFactory.query {
|
||||
val row = lockOperator(operatorId)
|
||||
val result = if (row == null) {
|
||||
AdminOperatorMutationResult.NOT_FOUND
|
||||
} else {
|
||||
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
|
||||
it[failedLoginCount] = 0
|
||||
it[lockedUntil] = null
|
||||
it[updatedAt] = now
|
||||
}
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
insertAudit(auditEvent.withResult(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun resetOperatorCredentials(
|
||||
operatorId: UUID,
|
||||
passwordHash: String,
|
||||
encryptedTotpSecret: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = databaseFactory.query {
|
||||
require(passwordHash.isNotBlank())
|
||||
require(encryptedTotpSecret.isNotBlank())
|
||||
val row = lockOperator(operatorId)
|
||||
val result = if (row == null) {
|
||||
AdminOperatorMutationResult.NOT_FOUND
|
||||
} else {
|
||||
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
|
||||
it[AdminOperatorsTable.passwordHash] = passwordHash
|
||||
it[AdminOperatorsTable.encryptedTotpSecret] = encryptedTotpSecret
|
||||
it[failedLoginCount] = 0
|
||||
it[lockedUntil] = null
|
||||
it[lastTotpCounter] = null
|
||||
it[updatedAt] = now
|
||||
}
|
||||
revokeActiveSessions(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
insertAudit(auditEvent.withResult(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun revokeOperatorSessions(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = databaseFactory.query {
|
||||
val row = lockOperator(operatorId)
|
||||
val result = if (row == null) {
|
||||
AdminOperatorMutationResult.NOT_FOUND
|
||||
} else {
|
||||
revokeActiveSessions(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
insertAudit(auditEvent.withResult(result))
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun findOperatorForAuthentication(
|
||||
normalizedUsername: String,
|
||||
): AdminOperatorAuthRecord? = databaseFactory.query {
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.username eq normalizedUsername }
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?.toAuthRecord()
|
||||
}
|
||||
|
||||
override suspend fun updateLockState(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
transform: (AdminLockState) -> AdminLockState,
|
||||
): AdminLockState? = databaseFactory.query {
|
||||
val row = AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq operatorId.toString() }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
if (row[AdminOperatorsTable.disabledAt] != null) return@query null
|
||||
|
||||
val next = transform(
|
||||
AdminLockState(
|
||||
failedLoginCount = row[AdminOperatorsTable.failedLoginCount],
|
||||
lockedUntil = row[AdminOperatorsTable.lockedUntil],
|
||||
),
|
||||
)
|
||||
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
|
||||
it[failedLoginCount] = next.failedLoginCount
|
||||
it[lockedUntil] = next.lockedUntil
|
||||
it[updatedAt] = now
|
||||
}
|
||||
insertAudit(auditEvent)
|
||||
next
|
||||
}
|
||||
|
||||
override suspend fun createSessionIfTotpCounterFresh(
|
||||
session: NewAdminSession,
|
||||
totpCounter: Long,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord? = databaseFactory.query {
|
||||
require(totpCounter >= 0)
|
||||
val operator = AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq session.operatorId.toString() }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
if (operator[AdminOperatorsTable.disabledAt] != null) return@query null
|
||||
if (operator[AdminOperatorsTable.lockedUntil]?.isAfter(now) == true) return@query null
|
||||
if (operator[AdminOperatorsTable.lastTotpCounter]?.let { it >= totpCounter } == true) {
|
||||
return@query null
|
||||
}
|
||||
|
||||
AdminOperatorsTable.update({ AdminOperatorsTable.id eq session.operatorId.toString() }) {
|
||||
it[lastTotpCounter] = totpCounter
|
||||
it[failedLoginCount] = 0
|
||||
it[lockedUntil] = null
|
||||
it[lastLoginAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
AdminSessionsTable.insert {
|
||||
it[id] = session.id.toString()
|
||||
it[operatorId] = session.operatorId.toString()
|
||||
it[tokenHash] = session.tokenHash
|
||||
it[csrfTokenHash] = session.csrfTokenHash
|
||||
it[createdAt] = session.createdAt
|
||||
it[expiresAt] = session.expiresAt
|
||||
}
|
||||
insertAudit(auditEvent)
|
||||
AdminSessionRecord(
|
||||
id = session.id,
|
||||
operatorId = session.operatorId,
|
||||
normalizedUsername = operator[AdminOperatorsTable.username],
|
||||
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
|
||||
csrfTokenHash = session.csrfTokenHash,
|
||||
expiresAt = session.expiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun findActiveSessionByTokenHash(
|
||||
tokenHash: String,
|
||||
now: Instant,
|
||||
): AdminSessionRecord? = databaseFactory.query {
|
||||
val session = AdminSessionsTable.selectAll()
|
||||
.where {
|
||||
(AdminSessionsTable.tokenHash eq tokenHash) and
|
||||
AdminSessionsTable.revokedAt.isNull() and
|
||||
(AdminSessionsTable.expiresAt greater now)
|
||||
}
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
val operator = activeOperator(session[AdminSessionsTable.operatorId]) ?: return@query null
|
||||
session.toSessionRecord(
|
||||
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
|
||||
normalizedUsername = operator[AdminOperatorsTable.username],
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun revokeSessionByTokenHash(
|
||||
tokenHash: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord? = databaseFactory.query {
|
||||
val session = AdminSessionsTable.selectAll()
|
||||
.where {
|
||||
(AdminSessionsTable.tokenHash eq tokenHash) and
|
||||
AdminSessionsTable.revokedAt.isNull()
|
||||
}
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
val operator = AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq session[AdminSessionsTable.operatorId] }
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
AdminSessionsTable.update({ AdminSessionsTable.id eq session[AdminSessionsTable.id] }) {
|
||||
it[revokedAt] = now
|
||||
}
|
||||
insertAudit(auditEvent)
|
||||
session.toSessionRecord(
|
||||
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
|
||||
normalizedUsername = operator[AdminOperatorsTable.username],
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..1_000)
|
||||
val candidateIds = AdminSessionsTable.selectAll()
|
||||
.where {
|
||||
(AdminSessionsTable.expiresAt lessEq cutoff) or
|
||||
(
|
||||
AdminSessionsTable.revokedAt.isNotNull() and
|
||||
(AdminSessionsTable.revokedAt lessEq cutoff)
|
||||
)
|
||||
}
|
||||
.orderBy(
|
||||
AdminSessionsTable.expiresAt to SortOrder.ASC,
|
||||
AdminSessionsTable.id to SortOrder.ASC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map { it[AdminSessionsTable.id] }
|
||||
if (candidateIds.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
AdminSessionsTable.deleteWhere {
|
||||
AdminSessionsTable.id inList candidateIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun appendAudit(event: NewAdminAuditEvent) {
|
||||
databaseFactory.query {
|
||||
insertAudit(event)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
): List<AdminAuditRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminAuditLogTable.selectAll()
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
(AdminAuditLogTable.occurredAt less before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
(AdminAuditLogTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
query
|
||||
.orderBy(
|
||||
AdminAuditLogTable.occurredAt to SortOrder.DESC,
|
||||
AdminAuditLogTable.id to SortOrder.DESC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map {
|
||||
AdminAuditRecord(
|
||||
id = UUID.fromString(it[AdminAuditLogTable.id]),
|
||||
actorOperatorId = it[AdminAuditLogTable.actorOperatorId]?.let(UUID::fromString),
|
||||
action = AdminAuditAction.valueOf(it[AdminAuditLogTable.action]),
|
||||
outcome = AdminAuditOutcome.valueOf(it[AdminAuditLogTable.outcome]),
|
||||
targetType = it[AdminAuditLogTable.targetType],
|
||||
targetId = it[AdminAuditLogTable.targetId],
|
||||
requestId = it[AdminAuditLogTable.requestId],
|
||||
occurredAt = it[AdminAuditLogTable.occurredAt],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAudit(event: NewAdminAuditEvent) {
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = event.id.toString()
|
||||
it[actorOperatorId] = event.actorOperatorId?.toString()
|
||||
it[action] = event.action.name
|
||||
it[outcome] = event.outcome.name
|
||||
it[targetType] = event.targetType
|
||||
it[targetId] = event.targetId
|
||||
it[requestId] = event.requestId
|
||||
it[occurredAt] = event.occurredAt
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOperatorIgnoringConflict(operator: NewAdminOperator): Boolean =
|
||||
AdminOperatorsTable.insertIgnore {
|
||||
it[id] = operator.id.toString()
|
||||
it[username] = operator.normalizedUsername
|
||||
it[passwordHash] = operator.passwordHash
|
||||
it[encryptedTotpSecret] = operator.encryptedTotpSecret
|
||||
it[role] = operator.role.name
|
||||
it[failedLoginCount] = 0
|
||||
it[lockedUntil] = null
|
||||
it[lastTotpCounter] = null
|
||||
it[lastLoginAt] = null
|
||||
it[disabledAt] = null
|
||||
it[createdAt] = operator.createdAt
|
||||
it[updatedAt] = operator.createdAt
|
||||
}.insertedCount > 0
|
||||
|
||||
private fun lockOperator(operatorId: UUID): ResultRow? =
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where { AdminOperatorsTable.id eq operatorId.toString() }
|
||||
.forUpdate()
|
||||
.singleOrNull()
|
||||
|
||||
private fun enabledSuperAdministratorCount(): Int =
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where {
|
||||
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
|
||||
AdminOperatorsTable.disabledAt.isNull()
|
||||
}
|
||||
.count()
|
||||
.toInt()
|
||||
|
||||
private fun revokeActiveSessions(operatorId: UUID, now: Instant) {
|
||||
AdminSessionsTable.update({
|
||||
(AdminSessionsTable.operatorId eq operatorId.toString()) and
|
||||
AdminSessionsTable.revokedAt.isNull()
|
||||
}) {
|
||||
it[revokedAt] = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeOperator(operatorId: String): ResultRow? =
|
||||
AdminOperatorsTable.selectAll()
|
||||
.where {
|
||||
(AdminOperatorsTable.id eq operatorId) and
|
||||
AdminOperatorsTable.disabledAt.isNull()
|
||||
}
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
private fun NewAdminAuditEvent.withResult(
|
||||
result: AdminOperatorMutationResult,
|
||||
): NewAdminAuditEvent = copy(
|
||||
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
|
||||
AdminAuditOutcome.SUCCESS
|
||||
} else {
|
||||
AdminAuditOutcome.DENIED
|
||||
},
|
||||
)
|
||||
|
||||
private fun ResultRow.toOperatorRecord(): AdminOperatorRecord = AdminOperatorRecord(
|
||||
id = UUID.fromString(this[AdminOperatorsTable.id]),
|
||||
normalizedUsername = this[AdminOperatorsTable.username],
|
||||
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
|
||||
lockState = AdminLockState(
|
||||
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
|
||||
lockedUntil = this[AdminOperatorsTable.lockedUntil],
|
||||
),
|
||||
disabledAt = this[AdminOperatorsTable.disabledAt],
|
||||
lastLoginAt = this[AdminOperatorsTable.lastLoginAt],
|
||||
createdAt = this[AdminOperatorsTable.createdAt],
|
||||
updatedAt = this[AdminOperatorsTable.updatedAt],
|
||||
)
|
||||
|
||||
private fun ResultRow.toAuthRecord(): AdminOperatorAuthRecord = AdminOperatorAuthRecord(
|
||||
id = UUID.fromString(this[AdminOperatorsTable.id]),
|
||||
normalizedUsername = this[AdminOperatorsTable.username],
|
||||
passwordHash = this[AdminOperatorsTable.passwordHash],
|
||||
encryptedTotpSecret = this[AdminOperatorsTable.encryptedTotpSecret],
|
||||
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
|
||||
lockState = AdminLockState(
|
||||
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
|
||||
lockedUntil = this[AdminOperatorsTable.lockedUntil],
|
||||
),
|
||||
disabledAt = this[AdminOperatorsTable.disabledAt],
|
||||
)
|
||||
|
||||
private fun ResultRow.toSessionRecord(
|
||||
role: AdminRole,
|
||||
normalizedUsername: String,
|
||||
): AdminSessionRecord = AdminSessionRecord(
|
||||
id = UUID.fromString(this[AdminSessionsTable.id]),
|
||||
operatorId = UUID.fromString(this[AdminSessionsTable.operatorId]),
|
||||
normalizedUsername = normalizedUsername,
|
||||
role = role,
|
||||
csrfTokenHash = this[AdminSessionsTable.csrfTokenHash],
|
||||
expiresAt = this[AdminSessionsTable.expiresAt],
|
||||
)
|
||||
@@ -0,0 +1,887 @@
|
||||
package com.osglab.account.features.admin.routes
|
||||
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.services.AdminAuditCursorException
|
||||
import com.osglab.account.features.admin.services.AdminAuditService
|
||||
import com.osglab.account.features.admin.services.AdminAuthService
|
||||
import com.osglab.account.features.admin.services.AdminOperatorCredentials
|
||||
import com.osglab.account.features.admin.services.AdminOperatorCursorException
|
||||
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
|
||||
import com.osglab.account.features.admin.services.AdminOperatorException
|
||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.http.content.staticResources
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.plugins.ratelimit.RateLimitName
|
||||
import io.ktor.server.plugins.ratelimit.rateLimit
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
fun Route.adminWebRoutes() {
|
||||
staticResources("/admin", "admin", index = "index.html")
|
||||
}
|
||||
|
||||
fun Route.adminApiRoutes(
|
||||
config: AppConfig,
|
||||
authService: AdminAuthService,
|
||||
sessionService: AdminSessionService,
|
||||
statsService: AdminStatsService,
|
||||
usersService: AdminUsersService,
|
||||
grantService: AdminGrantService,
|
||||
operatorService: AdminOperatorService,
|
||||
auditService: AdminAuditService,
|
||||
clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
route("/v1/admin") {
|
||||
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
|
||||
route("/auth") {
|
||||
get("/session") {
|
||||
if (!call.requireVerifiedAdminEdge()) return@get
|
||||
val principal = call.currentPrincipal(sessionService)
|
||||
call.respond(
|
||||
AdminSessionResponse(
|
||||
authenticated = principal != null,
|
||||
operatorName = principal?.normalizedUsername,
|
||||
role = principal?.role?.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/login") {
|
||||
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
|
||||
val request = call.receive<AdminLoginRequest>()
|
||||
val password = request.password.toCharArray()
|
||||
val result = try {
|
||||
authService.login(
|
||||
username = request.username,
|
||||
password = password,
|
||||
totpCode = request.totpCode,
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
)
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
when (result) {
|
||||
is AdminLoginResult.Authenticated -> {
|
||||
call.setAdminCookies(config, result.credentials.sessionToken, result.credentials.csrfToken)
|
||||
call.respond(
|
||||
AdminLoginResponse(
|
||||
operatorName = result.principal.normalizedUsername,
|
||||
role = result.principal.role.name,
|
||||
csrfToken = result.credentials.csrfToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
is AdminLoginResult.Locked -> call.respond(
|
||||
HttpStatusCode.TooManyRequests,
|
||||
AdminErrorResponse("RATE_LIMITED"),
|
||||
)
|
||||
AdminLoginResult.InvalidCredentials -> call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
AdminErrorResponse("INVALID_CREDENTIALS"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post("/logout") {
|
||||
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
|
||||
val sessionToken = call.request.cookies[SESSION_COOKIE]
|
||||
val csrfToken = call.request.header(CSRF_HEADER)
|
||||
if (
|
||||
sessionToken == null ||
|
||||
csrfToken == null ||
|
||||
!sessionService.revoke(
|
||||
sessionToken,
|
||||
csrfToken,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
) {
|
||||
call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
|
||||
return@post
|
||||
}
|
||||
call.clearAdminCookies(config)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get("/overview") {
|
||||
if (call.requirePrincipal(sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
call.respond(stats.toOverviewResponse())
|
||||
}
|
||||
|
||||
get("/referrals") {
|
||||
if (call.requirePrincipal(sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
call.respond(stats.toReferralResponse())
|
||||
}
|
||||
|
||||
get("/users") {
|
||||
if (
|
||||
call.requireRole(
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val query = call.request.queryParameters["q"]?.trim().orEmpty()
|
||||
val page = try {
|
||||
if (query.isEmpty()) {
|
||||
usersService.list(
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
)
|
||||
} else {
|
||||
usersService.searchByInternalId(query)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
call.respond(
|
||||
PageResponse(
|
||||
items = page.items.map(AdminUserSummaryDto::toUserSummaryResponse),
|
||||
nextCursor = page.nextCursor,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
get("/users/{userId}") {
|
||||
if (
|
||||
call.requireRole(
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val userId = call.uuidPathParameter("userId") ?: return@get
|
||||
try {
|
||||
call.respond(usersService.detail(userId).toUserDetailResponse())
|
||||
} catch (_: AdminUserNotFoundException) {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
|
||||
}
|
||||
}
|
||||
|
||||
get("/users/{userId}/ledger") {
|
||||
if (
|
||||
call.requireRole(
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val userId = call.uuidPathParameter("userId") ?: return@get
|
||||
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
try {
|
||||
val page = usersService.ledger(
|
||||
userId = userId,
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
|
||||
page.nextCursor,
|
||||
),
|
||||
)
|
||||
} catch (_: AdminUserNotFoundException) {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
}
|
||||
}
|
||||
|
||||
post("/credits/grants") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return@post
|
||||
}
|
||||
val request = call.receive<AdminGrantRequest>()
|
||||
val idempotencyKey = call.request.header("Idempotency-Key")
|
||||
val userId = runCatching { UUID.fromString(request.userId) }.getOrNull()
|
||||
if (
|
||||
userId == null ||
|
||||
request.amount !in 1..config.admin.maximumManualGrant ||
|
||||
request.reason.trim().length !in 4..200 ||
|
||||
idempotencyKey == null ||
|
||||
idempotencyKey.length !in 8..128
|
||||
) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@post
|
||||
}
|
||||
val result = try {
|
||||
grantService.grant(
|
||||
ManualGrantCommand(
|
||||
operatorId = principal.operatorId,
|
||||
userId = userId,
|
||||
credits = request.amount,
|
||||
reason = request.reason.trim(),
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
idempotencyKey = idempotencyKey,
|
||||
),
|
||||
)
|
||||
} catch (_: CreditNotFound) {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
|
||||
return@post
|
||||
} catch (_: CreditConflict) {
|
||||
call.respond(HttpStatusCode.Conflict, AdminErrorResponse("IDEMPOTENCY_CONFLICT"))
|
||||
return@post
|
||||
} catch (_: InvalidCreditRequest) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
AdminGrantResponse(
|
||||
transactionId = result.ledgerEntryId,
|
||||
balanceAfter = result.balanceAfter,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
get("/operators/summary") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
try {
|
||||
val summary = operatorService.summary(principal)
|
||||
call.respond(
|
||||
AdminSecuritySummaryResponse(
|
||||
enabledOperators = summary.enabledOperators,
|
||||
lockedOperators = summary.lockedOperators,
|
||||
activeSessions = summary.activeSessions,
|
||||
),
|
||||
)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
get("/operators") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
try {
|
||||
val page = operatorService.listPage(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
items = page.items.map(AdminOperatorRecord::toResponse),
|
||||
nextCursor = page.nextCursor,
|
||||
),
|
||||
)
|
||||
} catch (_: AdminOperatorCursorException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val request = call.receiveAdminRequest<AdminOperatorCreateRequest>() ?: return@post
|
||||
val password = request.password.toCharArray()
|
||||
try {
|
||||
val created = operatorService.create(
|
||||
actor = principal,
|
||||
username = request.username,
|
||||
password = password,
|
||||
roleName = request.role,
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(HttpStatusCode.Created, created.toProvisioningResponse())
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators/{operatorId}/enable") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
|
||||
try {
|
||||
operatorService.setEnabled(
|
||||
principal,
|
||||
operatorId,
|
||||
enabled = true,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators/{operatorId}/disable") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
|
||||
try {
|
||||
operatorService.setEnabled(
|
||||
principal,
|
||||
operatorId,
|
||||
enabled = false,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators/{operatorId}/unlock") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
|
||||
try {
|
||||
operatorService.unlock(
|
||||
principal,
|
||||
operatorId,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators/{operatorId}/credentials/reset") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
|
||||
val request = call.receiveAdminRequest<AdminOperatorPasswordRequest>() ?: return@post
|
||||
val password = request.password.toCharArray()
|
||||
try {
|
||||
val credentials = operatorService.resetCredentials(
|
||||
principal,
|
||||
operatorId,
|
||||
password,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(credentials.toProvisioningResponse(operatorId))
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
}
|
||||
|
||||
post("/operators/{operatorId}/sessions/revoke") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
|
||||
try {
|
||||
operatorService.revokeSessions(
|
||||
principal,
|
||||
operatorId,
|
||||
call.request.header("X-Request-ID"),
|
||||
)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
}
|
||||
}
|
||||
|
||||
get("/audit") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val page = try {
|
||||
auditService.list(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
return@get
|
||||
} catch (_: AdminAuditCursorException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
call.respond(
|
||||
PageResponse(
|
||||
items = page.items.map { item ->
|
||||
val audit = item.record
|
||||
AdminAuditResponse(
|
||||
auditId = audit.id.toString(),
|
||||
operatorName = item.operatorName,
|
||||
action = audit.action.name,
|
||||
targetType = audit.targetType ?: "NONE",
|
||||
targetId = audit.targetId ?: "—",
|
||||
requestId = audit.requestId,
|
||||
result = if (audit.outcome.name == "SUCCESS") "success" else "rejected",
|
||||
createdAt = audit.occurredAt.toString(),
|
||||
)
|
||||
},
|
||||
nextCursor = page.nextCursor,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AdminStatsService.getRange(
|
||||
range: String?,
|
||||
clock: Clock,
|
||||
): AdminStatsDto? {
|
||||
val days = when (range) {
|
||||
null, "30d" -> 30L
|
||||
"7d" -> 7L
|
||||
"90d" -> 90L
|
||||
else -> return null
|
||||
}
|
||||
val until = clock.instant()
|
||||
return get(until.minus(Duration.ofDays(days)), until)
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requirePrincipal(
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
if (!requireVerifiedAdminEdge()) return null
|
||||
val principal = currentPrincipal(sessions)
|
||||
if (principal == null) {
|
||||
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireRole(
|
||||
sessions: AdminSessionService,
|
||||
allowedRoles: Set<AdminRole>,
|
||||
): AdminPrincipal? {
|
||||
val principal = requirePrincipal(sessions) ?: return null
|
||||
if (principal.role !in allowedRoles) {
|
||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return null
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
|
||||
val sessionToken = request.cookies[SESSION_COOKIE]
|
||||
val csrfToken = request.header(CSRF_HEADER)
|
||||
val principal = if (sessionToken != null && csrfToken != null) {
|
||||
sessions.authenticateMutation(sessionToken, csrfToken)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (principal == null) {
|
||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("CSRF_INVALID"))
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
|
||||
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
|
||||
respond(HttpStatusCode.NotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
|
||||
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
|
||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.currentPrincipal(
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? = request.cookies[SESSION_COOKIE]?.let { sessions.authenticate(it) }
|
||||
|
||||
private suspend fun ApplicationCall.uuidPathParameter(name: String): UUID? {
|
||||
val value = parameters[name]
|
||||
val parsed = value?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
if (parsed == null) respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return parsed
|
||||
}
|
||||
|
||||
private fun ApplicationCall.pageLimit(
|
||||
maximum: Int,
|
||||
default: Int = 50,
|
||||
): Int? {
|
||||
val raw = request.queryParameters["limit"] ?: return default
|
||||
return raw.toIntOrNull()?.takeIf { it in 1..maximum }
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Any> ApplicationCall.receiveAdminRequest(): T? =
|
||||
try {
|
||||
receive<T>()
|
||||
} catch (_: BadRequestException) {
|
||||
respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondOperatorError(exception: AdminOperatorException) {
|
||||
val status = when (exception.code) {
|
||||
AdminOperatorErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
|
||||
AdminOperatorErrorCode.INSUFFICIENT_PERMISSION -> HttpStatusCode.Forbidden
|
||||
AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND -> HttpStatusCode.NotFound
|
||||
AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT,
|
||||
AdminOperatorErrorCode.CANNOT_DISABLE_SELF,
|
||||
AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED,
|
||||
-> HttpStatusCode.Conflict
|
||||
}
|
||||
respond(status, AdminErrorResponse(exception.code.name))
|
||||
}
|
||||
|
||||
private fun ApplicationCall.setAdminCookies(config: AppConfig, sessionToken: String, csrfToken: String) {
|
||||
response.cookies.append(adminCookie(SESSION_COOKIE, sessionToken, config, httpOnly = true))
|
||||
response.cookies.append(adminCookie(CSRF_COOKIE, csrfToken, config, httpOnly = false))
|
||||
}
|
||||
|
||||
private fun ApplicationCall.clearAdminCookies(config: AppConfig) {
|
||||
response.cookies.append(adminCookie(SESSION_COOKIE, "", config, httpOnly = true, maxAge = 0))
|
||||
response.cookies.append(adminCookie(CSRF_COOKIE, "", config, httpOnly = false, maxAge = 0))
|
||||
}
|
||||
|
||||
private fun adminCookie(
|
||||
name: String,
|
||||
value: String,
|
||||
config: AppConfig,
|
||||
httpOnly: Boolean,
|
||||
maxAge: Int? = null,
|
||||
): Cookie = Cookie(
|
||||
name = name,
|
||||
value = value,
|
||||
path = "/",
|
||||
maxAge = maxAge,
|
||||
secure = config.isProduction,
|
||||
httpOnly = httpOnly,
|
||||
extensions = mapOf("SameSite" to "Strict"),
|
||||
)
|
||||
|
||||
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
||||
val consumedByDate = creditFlow.associateBy { it.date }
|
||||
return AdminOverviewResponse(
|
||||
totalUsers = overview.totalUsers,
|
||||
activeUsers = overview.activeUsers,
|
||||
newUsers = overview.registrations,
|
||||
totalCreditBalance = overview.totalCreditBalance,
|
||||
creditsGranted = overview.issuedCredits,
|
||||
creditsUsed = overview.consumedCredits,
|
||||
trend = registrationTrend.map {
|
||||
AdminTrendResponse(
|
||||
date = it.date,
|
||||
registrations = it.registrations,
|
||||
creditsUsed = consumedByDate[it.date]?.consumedCredits ?: 0,
|
||||
)
|
||||
},
|
||||
usage = usage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
||||
AdminReferralResponse(
|
||||
pendingBindings = referralFunnel.pendingBindings,
|
||||
ineligibleBindings = referralFunnel.ineligibleBindings,
|
||||
funnel = listOf(
|
||||
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
|
||||
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
||||
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
|
||||
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
|
||||
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
|
||||
),
|
||||
ranking = referralRanking.map {
|
||||
AdminReferralRankResponse(
|
||||
userId = it.userId,
|
||||
invited = it.invitedUsers,
|
||||
qualified = it.rewardedUsers,
|
||||
creditsEarned = it.earnedCredits,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
||||
AdminUserSummaryResponse(
|
||||
userId = id,
|
||||
displayName = "用户 ${id.take(8)}",
|
||||
status = if (antiAbuseRestricted) "suspended" else "active",
|
||||
creditBalance = creditBalance,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
||||
AdminUserDetailResponse(
|
||||
summary = summary.toUserSummaryResponse(),
|
||||
lastActiveAt = summary.lastActiveAt,
|
||||
qualifiedUsage = summary.usageRequests > 0,
|
||||
referralCode = referralCode,
|
||||
referredByUserId = referral.inviterUserId,
|
||||
usage = usage,
|
||||
referral = referral,
|
||||
)
|
||||
|
||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||
AdminLedgerResponse(
|
||||
entryId = id,
|
||||
type = when (type) {
|
||||
"USAGE_RESERVE" -> "reserve"
|
||||
"USAGE_SETTLE" -> "settle"
|
||||
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
|
||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
|
||||
else -> "adjustment"
|
||||
},
|
||||
amount = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
reasonCode = type,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
|
||||
AdminOperatorResponse(
|
||||
operatorId = id.toString(),
|
||||
username = normalizedUsername,
|
||||
role = role.name,
|
||||
enabled = disabledAt == null,
|
||||
failedLoginCount = lockState.failedLoginCount,
|
||||
lockedUntil = lockState.lockedUntil?.toString(),
|
||||
lastLoginAt = lastLoginAt?.toString(),
|
||||
createdAt = createdAt.toString(),
|
||||
updatedAt = updatedAt.toString(),
|
||||
)
|
||||
|
||||
private fun AdminOperatorCredentials.toProvisioningResponse(
|
||||
fallbackOperatorId: UUID? = null,
|
||||
): AdminOperatorProvisioningResponse = AdminOperatorProvisioningResponse(
|
||||
operator = operator?.toResponse(),
|
||||
operatorId = operator?.id?.toString() ?: requireNotNull(fallbackOperatorId).toString(),
|
||||
totpSecret = totpSecret,
|
||||
otpauthUri = otpauthUri,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val totpCode: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminLoginResponse(
|
||||
val operatorName: String,
|
||||
val role: String,
|
||||
val csrfToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminSessionResponse(
|
||||
val authenticated: Boolean,
|
||||
val operatorName: String? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminErrorResponse(val code: String)
|
||||
|
||||
@Serializable
|
||||
private data class AdminGrantRequest(val userId: String, val amount: Long, val reason: String)
|
||||
|
||||
@Serializable
|
||||
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorCreateRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorPasswordRequest(val password: String)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorResponse(
|
||||
val operatorId: String,
|
||||
val username: String,
|
||||
val role: String,
|
||||
val enabled: Boolean,
|
||||
val failedLoginCount: Int,
|
||||
val lockedUntil: String?,
|
||||
val lastLoginAt: String?,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorProvisioningResponse(
|
||||
val operatorId: String,
|
||||
val operator: AdminOperatorResponse? = null,
|
||||
val totpSecret: String,
|
||||
val otpauthUri: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminSecuritySummaryResponse(
|
||||
val enabledOperators: Int,
|
||||
val lockedOperators: Int,
|
||||
val activeSessions: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PageResponse<T>(val items: List<T>, val nextCursor: String? = null)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOverviewResponse(
|
||||
val totalUsers: Long,
|
||||
val activeUsers: Long,
|
||||
val newUsers: Long,
|
||||
val totalCreditBalance: Long,
|
||||
val creditsGranted: Long,
|
||||
val creditsUsed: Long,
|
||||
val trend: List<AdminTrendResponse>,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminTrendResponse(
|
||||
val date: String,
|
||||
val registrations: Long,
|
||||
val creditsUsed: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminReferralResponse(
|
||||
val pendingBindings: Long,
|
||||
val ineligibleBindings: Long,
|
||||
val funnel: List<AdminFunnelResponse>,
|
||||
val ranking: List<AdminReferralRankResponse>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminFunnelResponse(val label: String, val count: Long)
|
||||
|
||||
@Serializable
|
||||
private data class AdminReferralRankResponse(
|
||||
val userId: String,
|
||||
val invited: Long,
|
||||
val qualified: Long,
|
||||
val creditsEarned: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminUserSummaryResponse(
|
||||
val userId: String,
|
||||
val displayName: String,
|
||||
val status: String,
|
||||
val creditBalance: Long,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminUserDetailResponse(
|
||||
val userId: String,
|
||||
val displayName: String,
|
||||
val status: String,
|
||||
val creditBalance: Long,
|
||||
val createdAt: String,
|
||||
val lastActiveAt: String?,
|
||||
val qualifiedUsage: Boolean,
|
||||
val referralCode: String?,
|
||||
val referredByUserId: String?,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
val referral: AdminUserReferralDto,
|
||||
) {
|
||||
constructor(
|
||||
summary: AdminUserSummaryResponse,
|
||||
lastActiveAt: String?,
|
||||
qualifiedUsage: Boolean,
|
||||
referralCode: String?,
|
||||
referredByUserId: String?,
|
||||
usage: List<AdminUsageAggregateDto>,
|
||||
referral: AdminUserReferralDto,
|
||||
) : this(
|
||||
userId = summary.userId,
|
||||
displayName = summary.displayName,
|
||||
status = summary.status,
|
||||
creditBalance = summary.creditBalance,
|
||||
createdAt = summary.createdAt,
|
||||
lastActiveAt = lastActiveAt,
|
||||
qualifiedUsage = qualifiedUsage,
|
||||
referralCode = referralCode,
|
||||
referredByUserId = referredByUserId,
|
||||
usage = usage,
|
||||
referral = referral,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class AdminLedgerResponse(
|
||||
val entryId: String,
|
||||
val type: String,
|
||||
val amount: Long,
|
||||
val balanceAfter: Long,
|
||||
val reasonCode: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AdminAuditResponse(
|
||||
val auditId: String,
|
||||
val operatorName: String,
|
||||
val action: String,
|
||||
val targetType: String,
|
||||
val targetId: String,
|
||||
val requestId: String? = null,
|
||||
val result: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
private const val MTLS_HEADER = "X-OSG-mTLS-Verified"
|
||||
private const val MTLS_VERIFIED = "SUCCESS"
|
||||
private const val SESSION_COOKIE = "osg_admin_session"
|
||||
private const val CSRF_COOKIE = "osg_admin_csrf"
|
||||
private const val CSRF_HEADER = "X-CSRF-Token"
|
||||
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.osglab.account.features.admin.security
|
||||
|
||||
import com.osglab.account.common.security.TokenHash
|
||||
|
||||
/**
|
||||
* Validates a double-submit CSRF token against the digest bound to the server
|
||||
* session. Cookie/header extraction remains a route-layer concern.
|
||||
*/
|
||||
class AdminCsrfVerifier {
|
||||
fun verify(presentedToken: String, expectedHash: String): Boolean {
|
||||
if (presentedToken.isBlank() || presentedToken.length > MAX_TOKEN_LENGTH) return false
|
||||
if (!SHA256_HEX.matches(expectedHash)) return false
|
||||
return TokenHash.matches(presentedToken, expectedHash)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_TOKEN_LENGTH = 512
|
||||
val SHA256_HEX = Regex("^[0-9a-f]{64}$")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.osglab.account.features.admin.security
|
||||
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
|
||||
interface AdminPasswordHasher {
|
||||
fun hash(password: CharArray): String
|
||||
fun verify(password: CharArray, encodedHash: String): Boolean
|
||||
}
|
||||
|
||||
data class Argon2idConfig(
|
||||
val memoryKb: Int = 65_536,
|
||||
val iterations: Int = 3,
|
||||
val parallelism: Int = 1,
|
||||
val saltBytes: Int = 16,
|
||||
val hashBytes: Int = 32,
|
||||
) {
|
||||
init {
|
||||
require(memoryKb in MIN_MEMORY_KB..MAX_MEMORY_KB)
|
||||
require(iterations in 1..MAX_ITERATIONS)
|
||||
require(parallelism in 1..MAX_PARALLELISM)
|
||||
require(memoryKb >= parallelism * 8)
|
||||
require(saltBytes in 16..MAX_SALT_BYTES)
|
||||
require(hashBytes in 16..MAX_HASH_BYTES)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_MEMORY_KB = 8
|
||||
const val MAX_MEMORY_KB = 262_144
|
||||
const val MAX_ITERATIONS = 10
|
||||
const val MAX_PARALLELISM = 16
|
||||
const val MAX_SALT_BYTES = 64
|
||||
const val MAX_HASH_BYTES = 64
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PHC-compatible Argon2id password hashing backed by the project's existing
|
||||
* Bouncy Castle provider. Verification bounds parsed work factors to prevent a
|
||||
* malformed database value from causing excessive CPU or memory consumption.
|
||||
*/
|
||||
class BouncyCastleArgon2idPasswordHasher(
|
||||
private val config: Argon2idConfig = Argon2idConfig(),
|
||||
private val secureRandom: SecureRandom = SecureRandom(),
|
||||
) : AdminPasswordHasher {
|
||||
override fun hash(password: CharArray): String {
|
||||
requirePasswordLength(password)
|
||||
val salt = ByteArray(config.saltBytes).also(secureRandom::nextBytes)
|
||||
val digest = derive(password, salt, config)
|
||||
return try {
|
||||
val encoder = Base64.getEncoder().withoutPadding()
|
||||
"\$argon2id\$v=19\$m=${config.memoryKb},t=${config.iterations},p=${config.parallelism}\$" +
|
||||
"${encoder.encodeToString(salt)}\$${encoder.encodeToString(digest)}"
|
||||
} finally {
|
||||
salt.fill(0)
|
||||
digest.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun verify(password: CharArray, encodedHash: String): Boolean {
|
||||
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) return false
|
||||
val parsed = runCatching { parse(encodedHash) }.getOrNull() ?: return false
|
||||
val actual = runCatching { derive(password, parsed.salt, parsed.config) }.getOrNull()
|
||||
?: return false
|
||||
return try {
|
||||
MessageDigest.isEqual(actual, parsed.digest)
|
||||
} finally {
|
||||
actual.fill(0)
|
||||
parsed.salt.fill(0)
|
||||
parsed.digest.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun derive(password: CharArray, salt: ByteArray, parameters: Argon2idConfig): ByteArray {
|
||||
val generator = Argon2BytesGenerator()
|
||||
generator.init(
|
||||
Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
|
||||
.withMemoryAsKB(parameters.memoryKb)
|
||||
.withIterations(parameters.iterations)
|
||||
.withParallelism(parameters.parallelism)
|
||||
.withSalt(salt)
|
||||
.build(),
|
||||
)
|
||||
return ByteArray(parameters.hashBytes).also { generator.generateBytes(password, it) }
|
||||
}
|
||||
|
||||
private fun parse(encodedHash: String): ParsedHash {
|
||||
require(encodedHash.length <= MAX_ENCODED_HASH_CHARS)
|
||||
val parts = encodedHash.split('$')
|
||||
require(parts.size == 6 && parts[0].isEmpty())
|
||||
require(parts[1] == "argon2id" && parts[2] == "v=19")
|
||||
val parameterComponents = parts[3].split(',')
|
||||
require(parameterComponents.size == 3)
|
||||
val values = parameterComponents.associate { component ->
|
||||
val pair = component.split('=', limit = 2)
|
||||
require(pair.size == 2)
|
||||
pair[0] to pair[1].toInt()
|
||||
}
|
||||
require(values.keys == setOf("m", "t", "p"))
|
||||
val salt = Base64.getDecoder().decode(parts[4])
|
||||
val digest = Base64.getDecoder().decode(parts[5])
|
||||
val parsedConfig = Argon2idConfig(
|
||||
memoryKb = requireNotNull(values["m"]),
|
||||
iterations = requireNotNull(values["t"]),
|
||||
parallelism = requireNotNull(values["p"]),
|
||||
saltBytes = salt.size,
|
||||
hashBytes = digest.size,
|
||||
)
|
||||
return ParsedHash(parsedConfig, salt, digest)
|
||||
}
|
||||
|
||||
private fun requirePasswordLength(password: CharArray) {
|
||||
require(password.isNotEmpty()) { "Password must not be empty" }
|
||||
require(password.size <= MAX_PASSWORD_CHARS) { "Password exceeds maximum length" }
|
||||
}
|
||||
|
||||
private data class ParsedHash(
|
||||
val config: Argon2idConfig,
|
||||
val salt: ByteArray,
|
||||
val digest: ByteArray,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MAX_PASSWORD_CHARS = 1_024
|
||||
const val MAX_ENCODED_HASH_CHARS = 512
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.osglab.account.features.admin.security
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.net.URLEncoder
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
fun interface AdminTotpVerifier {
|
||||
/**
|
||||
* Returns the accepted moving counter. Persisting this counter with a
|
||||
* compare-and-set prevents reuse of an otherwise valid code.
|
||||
*/
|
||||
fun verify(secret: ByteArray, code: String, now: Instant): Long?
|
||||
}
|
||||
|
||||
class HmacTotpVerifier(
|
||||
private val digits: Int = 6,
|
||||
private val periodSeconds: Long = 30,
|
||||
private val allowedWindow: Int = 1,
|
||||
private val algorithm: String = "HmacSHA1",
|
||||
) : AdminTotpVerifier {
|
||||
init {
|
||||
require(digits in 6..8)
|
||||
require(periodSeconds in 15..120)
|
||||
require(allowedWindow in 0..10)
|
||||
require(algorithm in SUPPORTED_ALGORITHMS)
|
||||
}
|
||||
|
||||
override fun verify(secret: ByteArray, code: String, now: Instant): Long? {
|
||||
if (secret.size !in MIN_SECRET_BYTES..MAX_SECRET_BYTES) return null
|
||||
if (code.length != digits || code.any { it !in '0'..'9' }) return null
|
||||
if (now.epochSecond < 0) return null
|
||||
|
||||
val currentCounter = now.epochSecond / periodSeconds
|
||||
var acceptedCounter: Long? = null
|
||||
for (offset in -allowedWindow..allowedWindow) {
|
||||
val candidate = currentCounter + offset
|
||||
if (candidate < 0) continue
|
||||
val expected = generateForCounter(secret, candidate)
|
||||
if (
|
||||
MessageDigest.isEqual(
|
||||
code.toByteArray(Charsets.US_ASCII),
|
||||
expected.toByteArray(Charsets.US_ASCII),
|
||||
)
|
||||
) {
|
||||
// Prefer the newest matching counter if a short code collides
|
||||
// within the configured window.
|
||||
acceptedCounter = candidate
|
||||
}
|
||||
}
|
||||
return acceptedCounter
|
||||
}
|
||||
|
||||
fun generate(secret: ByteArray, at: Instant): String {
|
||||
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
|
||||
require(at.epochSecond >= 0)
|
||||
return generateForCounter(secret, at.epochSecond / periodSeconds)
|
||||
}
|
||||
|
||||
internal fun generateForCounter(secret: ByteArray, counter: Long): String {
|
||||
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
|
||||
require(counter >= 0)
|
||||
val mac = Mac.getInstance(algorithm)
|
||||
mac.init(SecretKeySpec(secret, algorithm))
|
||||
val digest = mac.doFinal(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array())
|
||||
val offset = digest.last().toInt() and 0x0f
|
||||
val binary = ((digest[offset].toInt() and 0x7f) shl 24) or
|
||||
((digest[offset + 1].toInt() and 0xff) shl 16) or
|
||||
((digest[offset + 2].toInt() and 0xff) shl 8) or
|
||||
(digest[offset + 3].toInt() and 0xff)
|
||||
return (binary % POWERS_OF_TEN[digits]).toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SUPPORTED_ALGORITHMS = setOf("HmacSHA1", "HmacSHA256", "HmacSHA512")
|
||||
val POWERS_OF_TEN = intArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
|
||||
const val MIN_SECRET_BYTES = 16
|
||||
const val MAX_SECRET_BYTES = 64
|
||||
}
|
||||
}
|
||||
|
||||
object Base32TotpSecret {
|
||||
fun encode(secret: ByteArray): String {
|
||||
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
|
||||
val result = StringBuilder((secret.size * 8 + 4) / 5)
|
||||
var buffer = 0
|
||||
var bufferedBits = 0
|
||||
secret.forEach { byte ->
|
||||
buffer = (buffer shl Byte.SIZE_BITS) or (byte.toInt() and 0xff)
|
||||
bufferedBits += Byte.SIZE_BITS
|
||||
while (bufferedBits >= 5) {
|
||||
bufferedBits -= 5
|
||||
result.append(ALPHABET[(buffer shr bufferedBits) and 0x1f])
|
||||
}
|
||||
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
|
||||
}
|
||||
if (bufferedBits > 0) {
|
||||
result.append(ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
fun decode(encoded: String): ByteArray {
|
||||
val normalized = encoded
|
||||
.trim()
|
||||
.replace(" ", "")
|
||||
.uppercase()
|
||||
.trimEnd('=')
|
||||
require(normalized.isNotEmpty()) { "TOTP secret must not be empty" }
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
var buffer = 0
|
||||
var bufferedBits = 0
|
||||
normalized.forEach { character ->
|
||||
val value = ALPHABET.indexOf(character)
|
||||
require(value >= 0) { "TOTP secret is not valid Base32" }
|
||||
buffer = (buffer shl 5) or value
|
||||
bufferedBits += 5
|
||||
if (bufferedBits >= Byte.SIZE_BITS) {
|
||||
bufferedBits -= Byte.SIZE_BITS
|
||||
output.write((buffer shr bufferedBits) and 0xff)
|
||||
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
|
||||
}
|
||||
}
|
||||
require(buffer == 0) { "TOTP secret has non-zero trailing bits" }
|
||||
return output.toByteArray().also {
|
||||
require(it.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) {
|
||||
"TOTP secret must contain 128 to 512 bits"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
private const val MIN_SECRET_BYTES = 16
|
||||
private const val MAX_SECRET_BYTES = 64
|
||||
}
|
||||
|
||||
class AdminTotpProvisioning(
|
||||
val secretBase32: String,
|
||||
val otpauthUri: String,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AdminTotpProvisioning(secretBase32=[REDACTED], otpauthUri=[REDACTED])"
|
||||
}
|
||||
|
||||
fun interface AdminTotpSecretGenerator {
|
||||
fun generate(normalizedUsername: String): AdminTotpProvisioning
|
||||
}
|
||||
|
||||
class SecureAdminTotpSecretGenerator(
|
||||
private val secureRandom: SecureRandom = SecureRandom(),
|
||||
private val issuer: String = "OSGKeyboard",
|
||||
) : AdminTotpSecretGenerator {
|
||||
init {
|
||||
require(issuer.isNotBlank())
|
||||
}
|
||||
|
||||
override fun generate(normalizedUsername: String): AdminTotpProvisioning {
|
||||
require(normalizedUsername.isNotBlank())
|
||||
val rawSecret = ByteArray(SECRET_BYTES).also(secureRandom::nextBytes)
|
||||
val secret = try {
|
||||
Base32TotpSecret.encode(rawSecret)
|
||||
} finally {
|
||||
rawSecret.fill(0)
|
||||
}
|
||||
val encodedIssuer = encodeUriComponent(issuer)
|
||||
val encodedLabel = encodeUriComponent("$issuer:$normalizedUsername")
|
||||
return AdminTotpProvisioning(
|
||||
secretBase32 = secret,
|
||||
otpauthUri = "otpauth://totp/$encodedLabel?secret=$secret&issuer=$encodedIssuer" +
|
||||
"&algorithm=SHA1&digits=6&period=30",
|
||||
)
|
||||
}
|
||||
|
||||
private fun encodeUriComponent(value: String): String =
|
||||
URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20")
|
||||
|
||||
private companion object {
|
||||
const val SECRET_BYTES = 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
class AdminAuditCursorException : RuntimeException("Invalid admin audit cursor")
|
||||
|
||||
data class AdminAuditItem(
|
||||
val record: AdminAuditRecord,
|
||||
val operatorName: String,
|
||||
)
|
||||
|
||||
data class AdminAuditPage(
|
||||
val items: List<AdminAuditItem>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
class AdminAuditService(
|
||||
private val repository: AdminRepository,
|
||||
) {
|
||||
suspend fun list(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
): AdminAuditPage {
|
||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
}
|
||||
require(limit in 1..MAX_PAGE_SIZE)
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listAudit(limit + 1, decodedCursor)
|
||||
val pageRecords = records.take(limit)
|
||||
val operatorNames = repository.listOperators().associate {
|
||||
it.id to it.normalizedUsername
|
||||
}
|
||||
return AdminAuditPage(
|
||||
items = pageRecords.map { record ->
|
||||
AdminAuditItem(
|
||||
record = record,
|
||||
operatorName = record.actorOperatorId?.let(operatorNames::get)
|
||||
?: record.actorOperatorId?.toString()
|
||||
?: "system",
|
||||
)
|
||||
},
|
||||
nextCursor = if (records.size > limit) {
|
||||
pageRecords.lastOrNull()?.let(::encodeCursor)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminAuditCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
AdminAuditCursor(
|
||||
occurredAt = Instant.ofEpochSecond(
|
||||
parts[0].toLong(),
|
||||
parts[1].toLong(),
|
||||
),
|
||||
id = UUID.fromString(parts[2]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminAuditCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminAuditRecord): String {
|
||||
val payload = buildString {
|
||||
append(record.occurredAt.epochSecond)
|
||||
append(':')
|
||||
append(record.occurredAt.nano)
|
||||
append(':')
|
||||
append(record.id)
|
||||
}
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
payload.toByteArray(StandardCharsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 100
|
||||
const val MAX_CURSOR_LENGTH = 256
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.common.security.SecureTokenGenerator
|
||||
import com.osglab.account.common.security.Sha256SecureTokenGenerator
|
||||
import com.osglab.account.common.security.TokenHash
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminSessionCredentials
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpVerifier
|
||||
import com.osglab.account.features.admin.security.Base32TotpSecret
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AdminLoginLockPolicy(
|
||||
private val maxFailedAttempts: Int = 5,
|
||||
private val lockDuration: Duration = Duration.ofMinutes(15),
|
||||
) {
|
||||
init {
|
||||
require(maxFailedAttempts in 2..20)
|
||||
require(!lockDuration.isNegative && !lockDuration.isZero)
|
||||
require(lockDuration <= Duration.ofHours(24))
|
||||
}
|
||||
|
||||
fun afterFailure(current: AdminLockState, now: Instant): AdminLockState {
|
||||
if (current.isLockedAt(now)) return current
|
||||
val failuresBeforeAttempt = if (
|
||||
current.lockedUntil != null && !current.lockedUntil.isAfter(now)
|
||||
) {
|
||||
0
|
||||
} else {
|
||||
current.failedLoginCount
|
||||
}
|
||||
val failures = failuresBeforeAttempt + 1
|
||||
return if (failures >= maxFailedAttempts) {
|
||||
AdminLockState(failedLoginCount = 0, lockedUntil = now.plus(lockDuration))
|
||||
} else {
|
||||
AdminLockState(failedLoginCount = failures, lockedUntil = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AdminAuthService(
|
||||
private val repository: AdminRepository,
|
||||
private val passwordHasher: AdminPasswordHasher,
|
||||
private val dummyPasswordHash: String,
|
||||
private val totpVerifier: AdminTotpVerifier,
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
sessionTtl: Duration,
|
||||
private val lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
|
||||
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
private val sessionTtl = sessionTtl.also {
|
||||
require(it in MIN_SESSION_TTL..MAX_SESSION_TTL)
|
||||
}
|
||||
|
||||
init {
|
||||
require(dummyPasswordHash.isNotBlank())
|
||||
}
|
||||
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: CharArray,
|
||||
totpCode: String,
|
||||
requestId: String? = null,
|
||||
): AdminLoginResult {
|
||||
val normalizedRequestId = validateRequestId(requestId)
|
||||
val normalizedUsername = normalizeAdminUsername(username)
|
||||
?: return AdminLoginResult.InvalidCredentials
|
||||
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) {
|
||||
return AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
|
||||
val now = clock.instant()
|
||||
val operator = repository.findOperatorForAuthentication(normalizedUsername)
|
||||
val passwordMatches = passwordHasher.verify(
|
||||
password,
|
||||
operator?.passwordHash ?: dummyPasswordHash,
|
||||
)
|
||||
if (operator == null) {
|
||||
repository.appendAudit(
|
||||
loginAudit(null, AdminAuditOutcome.DENIED, now, normalizedRequestId),
|
||||
)
|
||||
return AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
if (operator.disabledAt != null) {
|
||||
repository.appendAudit(
|
||||
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
|
||||
)
|
||||
return AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
if (operator.lockState.isLockedAt(now)) {
|
||||
// Password verification above keeps locked and unknown users on
|
||||
// the same expensive hashing path.
|
||||
repository.appendAudit(
|
||||
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
|
||||
)
|
||||
return AdminLoginResult.Locked(requireNotNull(operator.lockState.lockedUntil))
|
||||
}
|
||||
if (!passwordMatches) {
|
||||
return failAuthentication(operator.id, now, normalizedRequestId)
|
||||
}
|
||||
|
||||
val acceptedCounter = verifyTotp(operator.id, operator.encryptedTotpSecret, totpCode, now)
|
||||
?: return failAuthentication(operator.id, now, normalizedRequestId)
|
||||
val rawSessionToken = tokenGenerator.newRefreshToken()
|
||||
val rawCsrfToken = tokenGenerator.newRefreshToken()
|
||||
val session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = operator.id,
|
||||
tokenHash = TokenHash.sha256(rawSessionToken),
|
||||
csrfTokenHash = TokenHash.sha256(rawCsrfToken),
|
||||
createdAt = now,
|
||||
expiresAt = now.plus(sessionTtl),
|
||||
)
|
||||
val created = repository.createSessionIfTotpCounterFresh(
|
||||
session = session,
|
||||
totpCounter = acceptedCounter,
|
||||
now = now,
|
||||
auditEvent = loginAudit(
|
||||
operator.id,
|
||||
AdminAuditOutcome.SUCCESS,
|
||||
now,
|
||||
normalizedRequestId,
|
||||
),
|
||||
) ?: run {
|
||||
repository.appendAudit(
|
||||
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
|
||||
)
|
||||
return AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
|
||||
return AdminLoginResult.Authenticated(
|
||||
principal = AdminPrincipal(
|
||||
operatorId = created.operatorId,
|
||||
sessionId = created.id,
|
||||
normalizedUsername = created.normalizedUsername,
|
||||
role = created.role,
|
||||
),
|
||||
credentials = AdminSessionCredentials(
|
||||
sessionToken = rawSessionToken,
|
||||
csrfToken = rawCsrfToken,
|
||||
expiresAt = created.expiresAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun failAuthentication(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
requestId: String?,
|
||||
): AdminLoginResult {
|
||||
val state = repository.updateLockState(
|
||||
operatorId = operatorId,
|
||||
now = now,
|
||||
auditEvent = loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
|
||||
) {
|
||||
lockPolicy.afterFailure(it, now)
|
||||
}
|
||||
if (state == null) {
|
||||
repository.appendAudit(
|
||||
loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
|
||||
)
|
||||
return AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
return state.lockedUntil
|
||||
?.takeIf { it.isAfter(now) }
|
||||
?.let(AdminLoginResult::Locked)
|
||||
?: AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
|
||||
private fun verifyTotp(
|
||||
operatorId: UUID,
|
||||
encryptedSecret: String,
|
||||
code: String,
|
||||
now: Instant,
|
||||
): Long? {
|
||||
val secret = runCatching {
|
||||
Base32TotpSecret.decode(
|
||||
fieldEncryptor.decrypt(encryptedSecret, adminTotpContext(operatorId)),
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
return try {
|
||||
totpVerifier.verify(secret, code, now)
|
||||
} finally {
|
||||
secret.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginAudit(
|
||||
operatorId: UUID?,
|
||||
outcome: AdminAuditOutcome,
|
||||
now: Instant,
|
||||
requestId: String?,
|
||||
) = NewAdminAuditEvent(
|
||||
actorOperatorId = operatorId,
|
||||
action = if (outcome == AdminAuditOutcome.SUCCESS) {
|
||||
AdminAuditAction.LOGIN_SUCCEEDED
|
||||
} else {
|
||||
AdminAuditAction.LOGIN_FAILED
|
||||
},
|
||||
outcome = outcome,
|
||||
targetType = operatorId?.let { OPERATOR_TARGET },
|
||||
targetId = operatorId?.toString(),
|
||||
requestId = requestId,
|
||||
occurredAt = now,
|
||||
)
|
||||
|
||||
private fun validateRequestId(requestId: String?): String? {
|
||||
val normalized = requestId?.trim() ?: return null
|
||||
return normalized.takeIf {
|
||||
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val MIN_SESSION_TTL: Duration = Duration.ofMinutes(5)
|
||||
val MAX_SESSION_TTL: Duration = Duration.ofHours(24)
|
||||
const val MAX_PASSWORD_CHARS = 1_024
|
||||
const val MAX_REQUEST_ID_LENGTH = 128
|
||||
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
|
||||
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
|
||||
}
|
||||
}
|
||||
|
||||
fun adminTotpContext(operatorId: UUID): String = "admin-totp-secret:$operatorId"
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import com.osglab.account.features.admin.security.Base32TotpSecret
|
||||
import java.time.Clock
|
||||
import java.util.UUID
|
||||
|
||||
data class AdminBootstrapConfig(
|
||||
val enabled: Boolean,
|
||||
val operatorId: UUID?,
|
||||
val username: String?,
|
||||
val passwordHash: String?,
|
||||
val totpSecretBase32: String?,
|
||||
) {
|
||||
init {
|
||||
if (enabled) {
|
||||
requireNotNull(operatorId) { "Admin bootstrap operator ID is required" }
|
||||
require(!username.isNullOrBlank()) { "Admin bootstrap username is required" }
|
||||
require(passwordHash?.startsWith("\$argon2id\$v=19\$") == true) {
|
||||
"Admin bootstrap password must be an Argon2id PHC hash"
|
||||
}
|
||||
require(!totpSecretBase32.isNullOrBlank()) { "Admin bootstrap TOTP secret is required" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AdminBootstrapService(
|
||||
private val repository: AdminRepository,
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun initialize(config: AdminBootstrapConfig): Boolean {
|
||||
if (!config.enabled) return false
|
||||
val operatorId = requireNotNull(config.operatorId)
|
||||
val username = requireNotNull(
|
||||
normalizeAdminUsername(requireNotNull(config.username)),
|
||||
) { "Admin bootstrap username is invalid" }
|
||||
val totpSecret = requireNotNull(config.totpSecretBase32).trim().uppercase()
|
||||
Base32TotpSecret.decode(totpSecret).fill(0)
|
||||
|
||||
val created = repository.createOperatorIfAbsent(
|
||||
NewAdminOperator(
|
||||
id = operatorId,
|
||||
normalizedUsername = username,
|
||||
passwordHash = requireNotNull(config.passwordHash),
|
||||
encryptedTotpSecret = fieldEncryptor.encrypt(
|
||||
totpSecret,
|
||||
adminTotpContext(operatorId),
|
||||
),
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
createdAt = clock.instant(),
|
||||
),
|
||||
)
|
||||
if (!created) {
|
||||
val existing = repository.findOperatorForAuthentication(username)
|
||||
require(existing?.id == operatorId) {
|
||||
"Admin bootstrap configuration does not match the existing operator"
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
||||
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
enum class AdminOperatorErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
INSUFFICIENT_PERMISSION,
|
||||
ADMIN_OPERATOR_NOT_FOUND,
|
||||
ADMIN_USERNAME_CONFLICT,
|
||||
CANNOT_DISABLE_SELF,
|
||||
LAST_SUPER_ADMIN_REQUIRED,
|
||||
}
|
||||
|
||||
class AdminOperatorException(
|
||||
val code: AdminOperatorErrorCode,
|
||||
) : RuntimeException(code.name)
|
||||
|
||||
class AdminOperatorCursorException : RuntimeException("Invalid admin operator cursor")
|
||||
|
||||
class AdminOperatorCredentials(
|
||||
val operator: AdminOperatorRecord?,
|
||||
val totpSecret: String,
|
||||
val otpauthUri: String,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AdminOperatorCredentials(operator=${operator?.id}, totpSecret=[REDACTED], otpauthUri=[REDACTED])"
|
||||
}
|
||||
|
||||
data class AdminSecuritySummary(
|
||||
val enabledOperators: Int,
|
||||
val lockedOperators: Int,
|
||||
val activeSessions: Long,
|
||||
)
|
||||
|
||||
data class AdminOperatorPage(
|
||||
val items: List<AdminOperatorRecord>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
class AdminOperatorService(
|
||||
private val repository: AdminRepository,
|
||||
private val passwordHasher: AdminPasswordHasher,
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val totpSecretGenerator: AdminTotpSecretGenerator = SecureAdminTotpSecretGenerator(),
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun list(actor: AdminPrincipal): List<AdminOperatorRecord> {
|
||||
requireSuperAdministrator(actor)
|
||||
return repository.listOperators()
|
||||
}
|
||||
|
||||
suspend fun listPage(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
): AdminOperatorPage {
|
||||
requireSuperAdministrator(actor)
|
||||
if (limit !in 1..MAX_PAGE_SIZE) {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
|
||||
val items = records.take(limit)
|
||||
return AdminOperatorPage(
|
||||
items = items,
|
||||
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun summary(actor: AdminPrincipal): AdminSecuritySummary {
|
||||
requireSuperAdministrator(actor)
|
||||
val now = clock.instant()
|
||||
val operators = repository.listOperators()
|
||||
return AdminSecuritySummary(
|
||||
enabledOperators = operators.count { it.disabledAt == null },
|
||||
lockedOperators = operators.count {
|
||||
it.lockState.lockedUntil?.isAfter(now) == true
|
||||
},
|
||||
activeSessions = repository.countActiveSessions(now),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
actor: AdminPrincipal,
|
||||
username: String,
|
||||
password: CharArray,
|
||||
roleName: String,
|
||||
requestId: String? = null,
|
||||
): AdminOperatorCredentials = try {
|
||||
createInternal(actor, username, password, roleName, requestId)
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
|
||||
suspend fun setEnabled(
|
||||
actor: AdminPrincipal,
|
||||
operatorId: UUID,
|
||||
enabled: Boolean,
|
||||
requestId: String? = null,
|
||||
) {
|
||||
val action = if (enabled) {
|
||||
AdminAuditAction.OPERATOR_ENABLED
|
||||
} else {
|
||||
AdminAuditAction.OPERATOR_DISABLED
|
||||
}
|
||||
authorizeMutation(actor, action, operatorId, requestId)
|
||||
if (!enabled && actor.operatorId == operatorId) {
|
||||
auditDenied(actor, action, operatorId, requestId)
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.CANNOT_DISABLE_SELF)
|
||||
}
|
||||
mapMutationResult(
|
||||
repository.setOperatorEnabled(
|
||||
operatorId = operatorId,
|
||||
enabled = enabled,
|
||||
now = clock.instant(),
|
||||
auditEvent = audit(actor, action, operatorId, requestId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun unlock(
|
||||
actor: AdminPrincipal,
|
||||
operatorId: UUID,
|
||||
requestId: String? = null,
|
||||
) {
|
||||
authorizeMutation(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId)
|
||||
mapMutationResult(
|
||||
repository.unlockOperator(
|
||||
operatorId = operatorId,
|
||||
now = clock.instant(),
|
||||
auditEvent = audit(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun resetCredentials(
|
||||
actor: AdminPrincipal,
|
||||
operatorId: UUID,
|
||||
password: CharArray,
|
||||
requestId: String? = null,
|
||||
): AdminOperatorCredentials = try {
|
||||
resetCredentialsInternal(actor, operatorId, password, requestId)
|
||||
} finally {
|
||||
password.fill('\u0000')
|
||||
}
|
||||
|
||||
suspend fun revokeSessions(
|
||||
actor: AdminPrincipal,
|
||||
operatorId: UUID,
|
||||
requestId: String? = null,
|
||||
) {
|
||||
authorizeMutation(actor, AdminAuditAction.OPERATOR_SESSIONS_REVOKED, operatorId, requestId)
|
||||
mapMutationResult(
|
||||
repository.revokeOperatorSessions(
|
||||
operatorId = operatorId,
|
||||
now = clock.instant(),
|
||||
auditEvent = audit(
|
||||
actor,
|
||||
AdminAuditAction.OPERATOR_SESSIONS_REVOKED,
|
||||
operatorId,
|
||||
requestId,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun createInternal(
|
||||
actor: AdminPrincipal,
|
||||
username: String,
|
||||
password: CharArray,
|
||||
roleName: String,
|
||||
requestId: String?,
|
||||
): AdminOperatorCredentials {
|
||||
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREATED, null, requestId)
|
||||
val normalizedUsername = normalizeAdminUsername(username)
|
||||
val role = parseRole(roleName)
|
||||
if (normalizedUsername == null || role == null || !validPassword(password)) {
|
||||
auditDenied(
|
||||
actor,
|
||||
AdminAuditAction.OPERATOR_CREATED,
|
||||
null,
|
||||
requestId,
|
||||
safeUsernameTarget(username),
|
||||
)
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
|
||||
}
|
||||
|
||||
val operatorId = UUID.randomUUID()
|
||||
val now = clock.instant()
|
||||
val provisioning = totpSecretGenerator.generate(normalizedUsername)
|
||||
val operator = NewAdminOperator(
|
||||
id = operatorId,
|
||||
normalizedUsername = normalizedUsername,
|
||||
passwordHash = passwordHasher.hash(password),
|
||||
encryptedTotpSecret = fieldEncryptor.encrypt(
|
||||
provisioning.secretBase32,
|
||||
adminTotpContext(operatorId),
|
||||
),
|
||||
role = role,
|
||||
createdAt = now,
|
||||
)
|
||||
mapMutationResult(
|
||||
repository.createOperator(
|
||||
operator,
|
||||
audit(actor, AdminAuditAction.OPERATOR_CREATED, operatorId, requestId),
|
||||
),
|
||||
)
|
||||
return credentialsFromProvisioning(
|
||||
operator = operator.toRecord(),
|
||||
provisioning = provisioning,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resetCredentialsInternal(
|
||||
actor: AdminPrincipal,
|
||||
operatorId: UUID,
|
||||
password: CharArray,
|
||||
requestId: String?,
|
||||
): AdminOperatorCredentials {
|
||||
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
|
||||
if (!validPassword(password)) {
|
||||
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
|
||||
}
|
||||
val current = repository.findOperator(operatorId)
|
||||
if (current == null) {
|
||||
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
|
||||
}
|
||||
val provisioning = totpSecretGenerator.generate(current.normalizedUsername)
|
||||
val result = repository.resetOperatorCredentials(
|
||||
operatorId = operatorId,
|
||||
passwordHash = passwordHasher.hash(password),
|
||||
encryptedTotpSecret = fieldEncryptor.encrypt(
|
||||
provisioning.secretBase32,
|
||||
adminTotpContext(operatorId),
|
||||
),
|
||||
now = clock.instant(),
|
||||
auditEvent = audit(
|
||||
actor,
|
||||
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
|
||||
operatorId,
|
||||
requestId,
|
||||
),
|
||||
)
|
||||
mapMutationResult(result)
|
||||
return credentialsFromProvisioning(null, provisioning)
|
||||
}
|
||||
|
||||
private fun requireSuperAdministrator(actor: AdminPrincipal) {
|
||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun authorizeMutation(
|
||||
actor: AdminPrincipal,
|
||||
action: AdminAuditAction,
|
||||
operatorId: UUID?,
|
||||
requestId: String?,
|
||||
) {
|
||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||
auditDenied(actor, action, operatorId, requestId)
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun auditDenied(
|
||||
actor: AdminPrincipal,
|
||||
action: AdminAuditAction,
|
||||
operatorId: UUID?,
|
||||
requestId: String?,
|
||||
targetOverride: String? = null,
|
||||
) {
|
||||
repository.appendAudit(
|
||||
audit(
|
||||
actor = actor,
|
||||
action = action,
|
||||
operatorId = operatorId,
|
||||
requestId = requestId,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
targetOverride = targetOverride,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun audit(
|
||||
actor: AdminPrincipal,
|
||||
action: AdminAuditAction,
|
||||
operatorId: UUID?,
|
||||
requestId: String?,
|
||||
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
|
||||
targetOverride: String? = null,
|
||||
) = NewAdminAuditEvent(
|
||||
actorOperatorId = actor.operatorId,
|
||||
action = action,
|
||||
outcome = outcome,
|
||||
targetType = OPERATOR_TARGET,
|
||||
targetId = targetOverride ?: operatorId?.toString() ?: "NEW",
|
||||
requestId = normalizeAdminRequestId(requestId),
|
||||
occurredAt = clock.instant(),
|
||||
)
|
||||
|
||||
private fun mapMutationResult(result: AdminOperatorMutationResult) {
|
||||
when (result) {
|
||||
AdminOperatorMutationResult.SUCCESS -> Unit
|
||||
AdminOperatorMutationResult.NOT_FOUND ->
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
|
||||
AdminOperatorMutationResult.USERNAME_CONFLICT ->
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
|
||||
AdminOperatorMutationResult.LAST_SUPER_ADMIN ->
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validPassword(password: CharArray): Boolean =
|
||||
password.size in MIN_PASSWORD_CHARS..MAX_PASSWORD_CHARS
|
||||
|
||||
private fun parseRole(roleName: String): AdminRole? =
|
||||
runCatching { AdminRole.valueOf(roleName) }.getOrNull()
|
||||
|
||||
private fun safeUsernameTarget(username: String): String =
|
||||
username.trim().lowercase(Locale.ROOT).take(MAX_AUDIT_TARGET_CHARS).ifBlank { "INVALID" }
|
||||
|
||||
private fun NewAdminOperator.toRecord() = AdminOperatorRecord(
|
||||
id = id,
|
||||
normalizedUsername = normalizedUsername,
|
||||
role = role,
|
||||
lockState = com.osglab.account.features.admin.models.AdminLockState(0, null),
|
||||
disabledAt = null,
|
||||
lastLoginAt = null,
|
||||
createdAt = createdAt,
|
||||
updatedAt = createdAt,
|
||||
)
|
||||
|
||||
private fun credentialsFromProvisioning(
|
||||
operator: AdminOperatorRecord?,
|
||||
provisioning: AdminTotpProvisioning,
|
||||
) = AdminOperatorCredentials(
|
||||
operator = operator,
|
||||
totpSecret = provisioning.secretBase32,
|
||||
otpauthUri = provisioning.otpauthUri,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 100
|
||||
const val MAX_CURSOR_LENGTH = 256
|
||||
const val MIN_PASSWORD_CHARS = 12
|
||||
const val MAX_PASSWORD_CHARS = 1_024
|
||||
const val MAX_AUDIT_TARGET_CHARS = 128
|
||||
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminOperatorCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
AdminOperatorCursor(
|
||||
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
|
||||
id = UUID.fromString(parts[2]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminOperatorRecord): String {
|
||||
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
payload.toByteArray(StandardCharsets.UTF_8),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizeAdminUsername(value: String): String? {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.takeIf { ADMIN_USERNAME.matches(it) }
|
||||
}
|
||||
|
||||
fun normalizeAdminRequestId(value: String?): String? {
|
||||
val normalized = value?.trim() ?: return null
|
||||
return normalized.takeIf {
|
||||
it.length <= 128 && ADMIN_REQUEST_ID.matches(it)
|
||||
}
|
||||
}
|
||||
|
||||
private val ADMIN_USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
|
||||
private val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.TokenHash
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import com.osglab.account.features.admin.security.AdminCsrfVerifier
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
|
||||
class AdminSessionService(
|
||||
private val repository: AdminRepository,
|
||||
private val csrfVerifier: AdminCsrfVerifier = AdminCsrfVerifier(),
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun authenticate(sessionToken: String): AdminPrincipal? =
|
||||
findSession(sessionToken)?.toPrincipal()
|
||||
|
||||
suspend fun authenticateMutation(
|
||||
sessionToken: String,
|
||||
csrfToken: String,
|
||||
): AdminPrincipal? {
|
||||
val session = findSession(sessionToken) ?: return null
|
||||
if (!csrfVerifier.verify(csrfToken, session.csrfTokenHash)) return null
|
||||
return session.toPrincipal()
|
||||
}
|
||||
|
||||
suspend fun revoke(
|
||||
sessionToken: String,
|
||||
csrfToken: String,
|
||||
requestId: String? = null,
|
||||
): Boolean {
|
||||
val tokenHash = hashValidToken(sessionToken) ?: return false
|
||||
val now = clock.instant()
|
||||
val active = repository.findActiveSessionByTokenHash(tokenHash, now) ?: return false
|
||||
if (!csrfVerifier.verify(csrfToken, active.csrfTokenHash)) return false
|
||||
val event = NewAdminAuditEvent(
|
||||
actorOperatorId = active.operatorId,
|
||||
action = AdminAuditAction.SESSION_REVOKED,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = SESSION_TARGET,
|
||||
targetId = active.id.toString(),
|
||||
requestId = validateRequestId(requestId),
|
||||
occurredAt = now,
|
||||
)
|
||||
return repository.revokeSessionByTokenHash(tokenHash, now, event) != null
|
||||
}
|
||||
|
||||
suspend fun cleanupInactive(
|
||||
retention: Duration = DEFAULT_INACTIVE_RETENTION,
|
||||
limit: Int = DEFAULT_CLEANUP_BATCH_SIZE,
|
||||
): Int {
|
||||
require(!retention.isNegative && !retention.isZero)
|
||||
require(limit in 1..MAX_CLEANUP_BATCH_SIZE)
|
||||
return repository.purgeInactiveSessions(clock.instant().minus(retention), limit)
|
||||
}
|
||||
|
||||
private suspend fun findSession(sessionToken: String): AdminSessionRecord? {
|
||||
val tokenHash = hashValidToken(sessionToken) ?: return null
|
||||
return repository.findActiveSessionByTokenHash(tokenHash, clock.instant())
|
||||
}
|
||||
|
||||
private fun hashValidToken(token: String): String? {
|
||||
if (token.isBlank() || token.length > MAX_TOKEN_LENGTH) return null
|
||||
return TokenHash.sha256(token)
|
||||
}
|
||||
|
||||
private fun validateRequestId(requestId: String?): String? {
|
||||
val normalized = requestId?.trim() ?: return null
|
||||
return normalized.takeIf {
|
||||
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AdminSessionRecord.toPrincipal() = AdminPrincipal(
|
||||
operatorId = operatorId,
|
||||
sessionId = id,
|
||||
normalizedUsername = normalizedUsername,
|
||||
role = role,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MAX_TOKEN_LENGTH = 512
|
||||
const val MAX_REQUEST_ID_LENGTH = 128
|
||||
const val DEFAULT_CLEANUP_BATCH_SIZE = 500
|
||||
const val MAX_CLEANUP_BATCH_SIZE = 1_000
|
||||
const val SESSION_TARGET = "ADMIN_SESSION"
|
||||
val DEFAULT_INACTIVE_RETENTION: Duration = Duration.ofDays(7)
|
||||
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.osglab.account.features.admin.stats.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AdminStatsPeriodDto(
|
||||
val from: String,
|
||||
val until: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminOverviewDto(
|
||||
val totalUsers: Long,
|
||||
val registrations: Long,
|
||||
val activeUsers: Long,
|
||||
val totalCreditBalance: Long,
|
||||
val issuedCredits: Long,
|
||||
val consumedCredits: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminRegistrationPointDto(
|
||||
val date: String,
|
||||
val registrations: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminCreditFlowPointDto(
|
||||
val date: String,
|
||||
val issuedCredits: Long,
|
||||
val consumedCredits: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminReferralFunnelDto(
|
||||
val codesCreated: Long,
|
||||
val bindings: Long,
|
||||
val rewardedBindings: Long,
|
||||
val pendingBindings: Long,
|
||||
val ineligibleBindings: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminReferralRankDto(
|
||||
val userId: String,
|
||||
val invitedUsers: Long,
|
||||
val rewardedUsers: Long,
|
||||
val earnedCredits: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUsageAggregateDto(
|
||||
val kind: String,
|
||||
val requests: Long,
|
||||
val chargedCredits: Long,
|
||||
val asrMillis: Long,
|
||||
val inputTokens: Long,
|
||||
val outputTokens: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminStatsDto(
|
||||
val period: AdminStatsPeriodDto,
|
||||
val overview: AdminOverviewDto,
|
||||
val registrationTrend: List<AdminRegistrationPointDto>,
|
||||
val creditFlow: List<AdminCreditFlowPointDto>,
|
||||
val referralFunnel: AdminReferralFunnelDto,
|
||||
val referralRanking: List<AdminReferralRankDto>,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
)
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
package com.osglab.account.features.admin.stats.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import org.jetbrains.exposed.v1.core.IColumnType
|
||||
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
import java.math.BigDecimal
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
data class AdminStatsRange(
|
||||
val from: Instant,
|
||||
val until: Instant,
|
||||
) {
|
||||
init {
|
||||
require(from < until) { "Admin statistics range must be non-empty" }
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminStatsSnapshot(
|
||||
val overview: AdminOverviewDto,
|
||||
val registrationsByDate: Map<LocalDate, Long>,
|
||||
val issuedCreditsByDate: Map<LocalDate, Long>,
|
||||
val consumedCreditsByDate: Map<LocalDate, Long>,
|
||||
val referralFunnel: AdminReferralFunnelDto,
|
||||
val referralRanking: List<AdminReferralRankDto>,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
)
|
||||
|
||||
fun interface AdminStatsRepository {
|
||||
suspend fun load(range: AdminStatsRange): AdminStatsSnapshot
|
||||
}
|
||||
|
||||
internal data class AdminStatsAggregates(
|
||||
val overview: AdminOverviewDto,
|
||||
val registrationsByDate: Map<LocalDate, Long>,
|
||||
val issuedCreditsByDate: Map<LocalDate, Long>,
|
||||
val consumedCreditsByDate: Map<LocalDate, Long>,
|
||||
val referralFunnel: AdminReferralFunnelDto,
|
||||
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
|
||||
val referralCreditsByInviter: Map<String, Long>,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
)
|
||||
|
||||
internal data class ReferralBindingAggregateRow(
|
||||
val inviterUserId: String,
|
||||
val invitedUsers: Long,
|
||||
val rewardedUsers: Long,
|
||||
)
|
||||
|
||||
class ExposedAdminStatsRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : AdminStatsRepository {
|
||||
override suspend fun load(range: AdminStatsRange): AdminStatsSnapshot =
|
||||
databaseFactory.query {
|
||||
assembleAdminStats(loadAggregates(range))
|
||||
}
|
||||
|
||||
private fun loadAggregates(range: AdminStatsRange): AdminStatsAggregates =
|
||||
AdminStatsAggregates(
|
||||
overview = loadOverview(range),
|
||||
registrationsByDate = loadDailyAggregates(
|
||||
"""
|
||||
SELECT DATE(created_at) AS aggregate_date, COUNT(*) AS aggregate_value
|
||||
FROM accounts
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
GROUP BY DATE(created_at)
|
||||
""",
|
||||
range,
|
||||
),
|
||||
issuedCreditsByDate = loadDailyAggregates(
|
||||
"""
|
||||
SELECT DATE(created_at) AS aggregate_date,
|
||||
COALESCE(SUM(amount_delta), 0) AS aggregate_value
|
||||
FROM credit_ledger
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND amount_delta > 0
|
||||
AND entry_type IN (
|
||||
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
|
||||
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
|
||||
)
|
||||
GROUP BY DATE(created_at)
|
||||
""",
|
||||
range,
|
||||
),
|
||||
consumedCreditsByDate = loadDailyAggregates(
|
||||
"""
|
||||
SELECT DATE(created_at) AS aggregate_date,
|
||||
COALESCE(SUM(charged_credits), 0) AS aggregate_value
|
||||
FROM credit_usage_records
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
GROUP BY DATE(created_at)
|
||||
""",
|
||||
range,
|
||||
),
|
||||
referralFunnel = loadReferralFunnel(range),
|
||||
referralBindingsByInviter = loadReferralBindingsByInviter(range),
|
||||
referralCreditsByInviter = loadReferralCreditsByInviter(range),
|
||||
usage = loadUsage(range),
|
||||
)
|
||||
|
||||
private fun loadOverview(range: AdminStatsRange): AdminOverviewDto =
|
||||
querySingle(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM accounts) AS total_users,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM accounts
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS registrations,
|
||||
(
|
||||
SELECT COUNT(DISTINCT user_id)
|
||||
FROM credit_usage_records
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS active_users,
|
||||
(
|
||||
SELECT COALESCE(SUM(balance), 0)
|
||||
FROM credit_accounts
|
||||
) AS total_credit_balance,
|
||||
(
|
||||
SELECT COALESCE(SUM(amount_delta), 0)
|
||||
FROM credit_ledger
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND amount_delta > 0
|
||||
AND entry_type IN (
|
||||
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
|
||||
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
|
||||
)
|
||||
) AS issued_credits,
|
||||
(
|
||||
SELECT COALESCE(SUM(charged_credits), 0)
|
||||
FROM credit_usage_records
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS consumed_credits
|
||||
""",
|
||||
range.arguments(repetitions = 4),
|
||||
) { result ->
|
||||
AdminOverviewDto(
|
||||
totalUsers = result.exactLong("total_users"),
|
||||
registrations = result.exactLong("registrations"),
|
||||
activeUsers = result.exactLong("active_users"),
|
||||
totalCreditBalance = result.exactLong("total_credit_balance"),
|
||||
issuedCredits = result.exactLong("issued_credits"),
|
||||
consumedCredits = result.exactLong("consumed_credits"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto =
|
||||
querySingle(
|
||||
"""
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM referral_codes
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS codes_created,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM referral_bindings
|
||||
WHERE bound_at >= ? AND bound_at < ?
|
||||
) AS bindings,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM referral_bindings
|
||||
WHERE reward_status = 'REWARDED'
|
||||
AND rewarded_at >= ? AND rewarded_at < ?
|
||||
) AS rewarded_bindings,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM referral_bindings
|
||||
WHERE reward_status = 'PENDING'
|
||||
AND bound_at >= ? AND bound_at < ?
|
||||
) AS pending_bindings,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM referral_bindings
|
||||
WHERE reward_status = 'INELIGIBLE_BUDGET'
|
||||
AND bound_at >= ? AND bound_at < ?
|
||||
) AS ineligible_bindings
|
||||
""",
|
||||
range.arguments(repetitions = 5),
|
||||
) { result ->
|
||||
AdminReferralFunnelDto(
|
||||
codesCreated = result.exactLong("codes_created"),
|
||||
bindings = result.exactLong("bindings"),
|
||||
rewardedBindings = result.exactLong("rewarded_bindings"),
|
||||
pendingBindings = result.exactLong("pending_bindings"),
|
||||
ineligibleBindings = result.exactLong("ineligible_bindings"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadReferralBindingsByInviter(
|
||||
range: AdminStatsRange,
|
||||
): List<ReferralBindingAggregateRow> =
|
||||
queryRows(
|
||||
"""
|
||||
SELECT
|
||||
inviter_user_id,
|
||||
SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN reward_status = 'REWARDED'
|
||||
AND rewarded_at >= ? AND rewarded_at < ?
|
||||
THEN 1 ELSE 0
|
||||
END
|
||||
) AS rewarded_users
|
||||
FROM referral_bindings
|
||||
WHERE (bound_at >= ? AND bound_at < ?)
|
||||
OR (
|
||||
reward_status = 'REWARDED'
|
||||
AND rewarded_at >= ? AND rewarded_at < ?
|
||||
)
|
||||
GROUP BY inviter_user_id
|
||||
""",
|
||||
range.arguments(repetitions = 4),
|
||||
) { result ->
|
||||
ReferralBindingAggregateRow(
|
||||
inviterUserId = result.getString("inviter_user_id"),
|
||||
invitedUsers = result.exactLong("invited_users"),
|
||||
rewardedUsers = result.exactLong("rewarded_users"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map<String, Long> =
|
||||
queryRows(
|
||||
"""
|
||||
SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits
|
||||
FROM credit_ledger
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND entry_type = 'REFERRAL_INVITER'
|
||||
AND amount_delta > 0
|
||||
GROUP BY user_id
|
||||
""",
|
||||
range.arguments(),
|
||||
) { result ->
|
||||
result.getString("user_id") to result.exactLong("earned_credits")
|
||||
}.toMap()
|
||||
|
||||
private fun loadUsage(range: AdminStatsRange): List<AdminUsageAggregateDto> =
|
||||
queryRows(
|
||||
"""
|
||||
SELECT
|
||||
usage_kind,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(charged_credits), 0) AS charged_credits,
|
||||
COALESCE(SUM(asr_millis), 0) AS asr_millis,
|
||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) AS output_tokens
|
||||
FROM credit_usage_records
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
GROUP BY usage_kind
|
||||
ORDER BY usage_kind
|
||||
""",
|
||||
range.arguments(),
|
||||
) { result ->
|
||||
AdminUsageAggregateDto(
|
||||
kind = result.getString("usage_kind"),
|
||||
requests = result.exactLong("requests"),
|
||||
chargedCredits = result.exactLong("charged_credits"),
|
||||
asrMillis = result.exactLong("asr_millis"),
|
||||
inputTokens = result.exactLong("input_tokens"),
|
||||
outputTokens = result.exactLong("output_tokens"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadDailyAggregates(sql: String, range: AdminStatsRange): Map<LocalDate, Long> =
|
||||
queryRows(sql, range.arguments()) { result ->
|
||||
result.getObject("aggregate_date", LocalDate::class.java) to
|
||||
result.exactLong("aggregate_value")
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSnapshot =
|
||||
AdminStatsSnapshot(
|
||||
overview = aggregates.overview,
|
||||
registrationsByDate = aggregates.registrationsByDate,
|
||||
issuedCreditsByDate = aggregates.issuedCreditsByDate,
|
||||
consumedCreditsByDate = aggregates.consumedCreditsByDate,
|
||||
referralFunnel = aggregates.referralFunnel,
|
||||
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
|
||||
AdminReferralRankDto(
|
||||
userId = binding.inviterUserId,
|
||||
invitedUsers = binding.invitedUsers,
|
||||
rewardedUsers = binding.rewardedUsers,
|
||||
earnedCredits = aggregates.referralCreditsByInviter[binding.inviterUserId] ?: 0,
|
||||
)
|
||||
}.sortedWith(
|
||||
compareByDescending<AdminReferralRankDto>(AdminReferralRankDto::invitedUsers)
|
||||
.thenByDescending(AdminReferralRankDto::rewardedUsers)
|
||||
.thenBy(AdminReferralRankDto::userId),
|
||||
),
|
||||
usage = aggregates.usage.sortedBy(AdminUsageAggregateDto::kind),
|
||||
)
|
||||
|
||||
private fun AdminStatsRange.arguments(
|
||||
repetitions: Int = 1,
|
||||
): List<Pair<IColumnType<*>, Any?>> = buildList {
|
||||
repeat(repetitions) {
|
||||
add(INSTANT_COLUMN_TYPE to from)
|
||||
add(INSTANT_COLUMN_TYPE to until)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> querySingle(
|
||||
sql: String,
|
||||
arguments: List<Pair<IColumnType<*>, Any?>>,
|
||||
transform: (ResultSet) -> T,
|
||||
): T = queryRows(sql, arguments, transform).single()
|
||||
|
||||
private fun <T> queryRows(
|
||||
sql: String,
|
||||
arguments: List<Pair<IColumnType<*>, Any?>>,
|
||||
transform: (ResultSet) -> T,
|
||||
): List<T> = TransactionManager.current().exec(sql.trimIndent(), arguments) { result ->
|
||||
buildList {
|
||||
while (result.next()) {
|
||||
add(transform(result))
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
private fun ResultSet.exactLong(column: String): Long =
|
||||
requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" }
|
||||
.toExactLong()
|
||||
|
||||
internal fun BigDecimal.toExactLong(): Long = longValueExact()
|
||||
|
||||
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.osglab.account.features.admin.stats.services
|
||||
|
||||
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class AdminStatsService(
|
||||
private val repository: AdminStatsRepository,
|
||||
) {
|
||||
suspend fun get(
|
||||
from: Instant,
|
||||
until: Instant,
|
||||
referralRankLimit: Int = 20,
|
||||
): AdminStatsDto {
|
||||
require(from < until) { "Statistics range must be non-empty" }
|
||||
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
|
||||
val range = AdminStatsRange(from, until)
|
||||
val snapshot = repository.load(range)
|
||||
val dates = utcDates(range)
|
||||
return AdminStatsDto(
|
||||
period = AdminStatsPeriodDto(from.toString(), until.toString()),
|
||||
overview = snapshot.overview,
|
||||
registrationTrend = dates.map { date ->
|
||||
AdminRegistrationPointDto(
|
||||
date = date.toString(),
|
||||
registrations = snapshot.registrationsByDate[date] ?: 0,
|
||||
)
|
||||
},
|
||||
creditFlow = dates.map { date ->
|
||||
AdminCreditFlowPointDto(
|
||||
date = date.toString(),
|
||||
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
|
||||
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
|
||||
)
|
||||
},
|
||||
referralFunnel = snapshot.referralFunnel,
|
||||
referralRanking = snapshot.referralRanking.take(referralRankLimit),
|
||||
usage = snapshot.usage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
|
||||
val dates = mutableListOf<LocalDate>()
|
||||
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
while (date.atStartOfDay(ZoneOffset.UTC).toInstant() < range.until) {
|
||||
dates += date
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return dates
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.osglab.account.features.admin.users.models
|
||||
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AdminUserSummaryDto(
|
||||
val id: String,
|
||||
val createdAt: String,
|
||||
val antiAbuseRestricted: Boolean,
|
||||
val creditBalance: Long,
|
||||
val consumedCredits: Long,
|
||||
val manualGrantedCredits: Long,
|
||||
val usageRequests: Long,
|
||||
val lastActiveAt: String?,
|
||||
val invitedUsers: Long,
|
||||
val rewardedInvites: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUserPageDto(
|
||||
val items: List<AdminUserSummaryDto>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUserLedgerEntryDto(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val amountDelta: Long,
|
||||
val balanceAfter: Long,
|
||||
val referenceId: String?,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUserLedgerPageDto(
|
||||
val items: List<AdminUserLedgerEntryDto>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUserReferralDto(
|
||||
val inviterUserId: String?,
|
||||
val invitedUsers: Long,
|
||||
val rewardedInvites: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminUserDetailDto(
|
||||
val summary: AdminUserSummaryDto,
|
||||
val usage: List<AdminUsageAggregateDto>,
|
||||
val referral: AdminUserReferralDto,
|
||||
val recentLedger: List<AdminUserLedgerEntryDto>,
|
||||
val referralCode: String? = null,
|
||||
)
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
package com.osglab.account.features.admin.users.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class AdminUserCursor(
|
||||
val createdAt: Instant,
|
||||
val userId: UUID,
|
||||
)
|
||||
|
||||
data class AdminUserLedgerCursor(
|
||||
val createdAt: Instant,
|
||||
val ledgerEntryId: UUID,
|
||||
)
|
||||
|
||||
interface AdminUsersRepository {
|
||||
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
|
||||
|
||||
suspend fun exists(userId: UUID): Boolean
|
||||
|
||||
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
|
||||
|
||||
suspend fun listLedger(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
}
|
||||
|
||||
class ExposedAdminUsersRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : AdminUsersRepository {
|
||||
override suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
): List<AdminUserSummaryDto> = databaseFactory.query {
|
||||
val query = AdminUsersAccountsTable.selectAll()
|
||||
if (cursor != null) {
|
||||
query.where {
|
||||
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersAccountsTable.id less cursor.userId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
val accountRows = query
|
||||
.orderBy(
|
||||
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersAccountsTable.id to SortOrder.DESC,
|
||||
)
|
||||
.limit(limit)
|
||||
.toList()
|
||||
val support = loadSupport(accountRows.map { it.userId() }.toSet())
|
||||
accountRows.map { it.toSummary(support) }
|
||||
}
|
||||
|
||||
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
|
||||
AdminUsersAccountsTable.selectAll()
|
||||
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
||||
.limit(1)
|
||||
.any()
|
||||
}
|
||||
|
||||
override suspend fun findDetail(
|
||||
userId: UUID,
|
||||
ledgerLimit: Int,
|
||||
): AdminUserDetailDto? = databaseFactory.query {
|
||||
val account = AdminUsersAccountsTable.selectAll()
|
||||
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
||||
.singleOrNull()
|
||||
?: return@query null
|
||||
val support = loadSupport(setOf(userId))
|
||||
val invitations = support.bindings.filter { it.inviterUserId == userId }
|
||||
val inviter = support.bindings.singleOrNull { it.inviteeUserId == userId }?.inviterUserId
|
||||
val usage = support.usage.filter { it.userId == userId }
|
||||
.groupBy(UserUsageRow::kind)
|
||||
.map { (kind, records) ->
|
||||
AdminUsageAggregateDto(
|
||||
kind = kind.name,
|
||||
requests = records.size.toLong(),
|
||||
chargedCredits = records.exactSumOf(UserUsageRow::chargedCredits),
|
||||
asrMillis = records.exactSumOf { it.asrMillis ?: 0 },
|
||||
inputTokens = records.exactSumOf { it.inputTokens ?: 0 },
|
||||
outputTokens = records.exactSumOf { it.outputTokens ?: 0 },
|
||||
)
|
||||
}
|
||||
.sortedBy(AdminUsageAggregateDto::kind)
|
||||
val recentLedger = support.ledger.filter { it.userId == userId }
|
||||
.sortedWith(
|
||||
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
|
||||
.thenByDescending { it.id.toString() },
|
||||
)
|
||||
.take(ledgerLimit)
|
||||
.map(UserLedgerRow::toDto)
|
||||
AdminUserDetailDto(
|
||||
summary = account.toSummary(support),
|
||||
referralCode = findReferralCode(userId),
|
||||
usage = usage,
|
||||
referral = AdminUserReferralDto(
|
||||
inviterUserId = inviter?.toString(),
|
||||
invitedUsers = invitations.size.toLong(),
|
||||
rewardedInvites = invitations.count {
|
||||
it.status == ReferralRewardStatus.REWARDED
|
||||
}.toLong(),
|
||||
),
|
||||
recentLedger = recentLedger,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listLedger(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
val query = AdminUsersCreditLedgerTable.selectAll()
|
||||
if (cursor == null) {
|
||||
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
|
||||
} else {
|
||||
query.where {
|
||||
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
query.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map { it.toUserLedgerRow().toDto() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class UserSupportRows(
|
||||
val balances: Map<UUID, Long>,
|
||||
val ledger: List<UserLedgerRow>,
|
||||
val usage: List<UserUsageRow>,
|
||||
val bindings: List<UserReferralBindingRow>,
|
||||
)
|
||||
|
||||
private data class UserLedgerRow(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val type: LedgerEntryType,
|
||||
val amountDelta: Long,
|
||||
val balanceAfter: Long,
|
||||
val referenceId: UUID?,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
private data class UserUsageRow(
|
||||
val userId: UUID,
|
||||
val kind: UsageKind,
|
||||
val asrMillis: Long?,
|
||||
val inputTokens: Long?,
|
||||
val outputTokens: Long?,
|
||||
val chargedCredits: Long,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
private data class UserReferralBindingRow(
|
||||
val inviterUserId: UUID,
|
||||
val inviteeUserId: UUID,
|
||||
val status: ReferralRewardStatus,
|
||||
)
|
||||
|
||||
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
|
||||
if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList())
|
||||
val ids = userIds.map(UUID::toString)
|
||||
return UserSupportRows(
|
||||
balances = AdminUsersCreditAccountsTable.selectAll()
|
||||
.where { AdminUsersCreditAccountsTable.userId inList ids }
|
||||
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
|
||||
.toMap(),
|
||||
ledger = AdminUsersCreditLedgerTable.selectAll()
|
||||
.where { AdminUsersCreditLedgerTable.userId inList ids }
|
||||
.map(ResultRow::toUserLedgerRow),
|
||||
usage = AdminUsersCreditUsageTable.selectAll()
|
||||
.where { AdminUsersCreditUsageTable.userId inList ids }
|
||||
.map(ResultRow::toUserUsageRow),
|
||||
bindings = AdminUsersReferralBindingsTable.selectAll()
|
||||
.where {
|
||||
(AdminUsersReferralBindingsTable.inviterUserId inList ids) or
|
||||
(AdminUsersReferralBindingsTable.inviteeUserId inList ids)
|
||||
}
|
||||
.map(ResultRow::toUserReferralBindingRow),
|
||||
)
|
||||
}
|
||||
|
||||
private fun findReferralCode(userId: UUID): String? =
|
||||
AdminUsersReferralCodesTable.selectAll()
|
||||
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
|
||||
.orderBy(
|
||||
AdminUsersReferralCodesTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersReferralCodesTable.id to SortOrder.DESC,
|
||||
)
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?.get(AdminUsersReferralCodesTable.code)
|
||||
|
||||
private fun ResultRow.toSummary(support: UserSupportRows): AdminUserSummaryDto {
|
||||
val userId = userId()
|
||||
val usage = support.usage.filter { it.userId == userId }
|
||||
val ledger = support.ledger.filter { it.userId == userId }
|
||||
val invitations = support.bindings.filter { it.inviterUserId == userId }
|
||||
return AdminUserSummaryDto(
|
||||
id = userId.toString(),
|
||||
createdAt = this[AdminUsersAccountsTable.createdAt].toString(),
|
||||
antiAbuseRestricted = this[AdminUsersAccountsTable.antiAbuseRestricted],
|
||||
creditBalance = support.balances[userId] ?: 0,
|
||||
consumedCredits = usage.exactSumOf(UserUsageRow::chargedCredits),
|
||||
manualGrantedCredits = ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT }
|
||||
.exactSumOf(UserLedgerRow::amountDelta),
|
||||
usageRequests = usage.size.toLong(),
|
||||
lastActiveAt = usage.maxOfOrNull(UserUsageRow::createdAt)?.toString(),
|
||||
invitedUsers = invitations.size.toLong(),
|
||||
rewardedInvites = invitations.count {
|
||||
it.status == ReferralRewardStatus.REWARDED
|
||||
}.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private object AdminUsersAccountsTable : Table("accounts") {
|
||||
val id = varchar("id", 36)
|
||||
val antiAbuseRestricted = bool("anti_abuse_restricted")
|
||||
val createdAt = timestamp("created_at")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object AdminUsersCreditAccountsTable : Table("credit_accounts") {
|
||||
val userId = varchar("user_id", 36)
|
||||
val balance = long("balance")
|
||||
override val primaryKey = PrimaryKey(userId)
|
||||
}
|
||||
|
||||
private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
|
||||
val id = varchar("id", 36)
|
||||
val userId = varchar("user_id", 36)
|
||||
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
|
||||
val amountDelta = long("amount_delta")
|
||||
val balanceAfter = long("balance_after")
|
||||
val referenceId = varchar("reference_id", 36).nullable()
|
||||
val createdAt = timestamp("created_at")
|
||||
}
|
||||
|
||||
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
|
||||
val userId = varchar("user_id", 36)
|
||||
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
|
||||
val asrMillis = long("asr_millis").nullable()
|
||||
val inputTokens = long("input_tokens").nullable()
|
||||
val outputTokens = long("output_tokens").nullable()
|
||||
val chargedCredits = long("charged_credits")
|
||||
val createdAt = timestamp("created_at")
|
||||
}
|
||||
|
||||
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
|
||||
val inviterUserId = varchar("inviter_user_id", 36)
|
||||
val inviteeUserId = varchar("invitee_user_id", 36)
|
||||
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
|
||||
}
|
||||
|
||||
private object AdminUsersReferralCodesTable : Table("referral_codes") {
|
||||
val id = varchar("id", 36)
|
||||
val ownerUserId = varchar("owner_user_id", 36)
|
||||
val code = varchar("code", 32)
|
||||
val createdAt = timestamp("created_at")
|
||||
}
|
||||
|
||||
private fun ResultRow.userId(): UUID = UUID.fromString(this[AdminUsersAccountsTable.id])
|
||||
|
||||
private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
|
||||
id = UUID.fromString(this[AdminUsersCreditLedgerTable.id]),
|
||||
userId = UUID.fromString(this[AdminUsersCreditLedgerTable.userId]),
|
||||
type = this[AdminUsersCreditLedgerTable.entryType],
|
||||
amountDelta = this[AdminUsersCreditLedgerTable.amountDelta],
|
||||
balanceAfter = this[AdminUsersCreditLedgerTable.balanceAfter],
|
||||
referenceId = this[AdminUsersCreditLedgerTable.referenceId]?.let(UUID::fromString),
|
||||
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
|
||||
)
|
||||
|
||||
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
type = type.name,
|
||||
amountDelta = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
referenceId = referenceId?.toString(),
|
||||
createdAt = createdAt.toString(),
|
||||
)
|
||||
|
||||
private fun ResultRow.toUserUsageRow() = UserUsageRow(
|
||||
userId = UUID.fromString(this[AdminUsersCreditUsageTable.userId]),
|
||||
kind = this[AdminUsersCreditUsageTable.usageKind],
|
||||
asrMillis = this[AdminUsersCreditUsageTable.asrMillis],
|
||||
inputTokens = this[AdminUsersCreditUsageTable.inputTokens],
|
||||
outputTokens = this[AdminUsersCreditUsageTable.outputTokens],
|
||||
chargedCredits = this[AdminUsersCreditUsageTable.chargedCredits],
|
||||
createdAt = this[AdminUsersCreditUsageTable.createdAt],
|
||||
)
|
||||
|
||||
private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
|
||||
inviterUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviterUserId]),
|
||||
inviteeUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviteeUserId]),
|
||||
status = this[AdminUsersReferralBindingsTable.rewardStatus],
|
||||
)
|
||||
|
||||
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
|
||||
fold(0L) { total, item -> Math.addExact(total, value(item)) }
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.osglab.account.features.admin.users.services
|
||||
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserPageDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
class AdminUserNotFoundException : RuntimeException("Admin user view does not exist")
|
||||
|
||||
class AdminUsersService(
|
||||
private val repository: AdminUsersRepository,
|
||||
) {
|
||||
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
||||
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
|
||||
?: return AdminUserPageDto(emptyList(), null)
|
||||
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
|
||||
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
|
||||
}
|
||||
|
||||
suspend fun list(
|
||||
limit: Int = 50,
|
||||
cursor: String? = null,
|
||||
): AdminUserPageDto {
|
||||
require(limit in 1..100) { "User page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
|
||||
val results = repository.list(limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
val nextCursor = if (hasMore) {
|
||||
val last = items.last()
|
||||
AdminUserCursorCodec.encode(
|
||||
AdminUserCursor(
|
||||
createdAt = Instant.parse(last.createdAt),
|
||||
userId = UUID.fromString(last.id),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return AdminUserPageDto(items, nextCursor)
|
||||
}
|
||||
|
||||
suspend fun detail(
|
||||
userId: UUID,
|
||||
ledgerLimit: Int = 50,
|
||||
): AdminUserDetailDto {
|
||||
require(ledgerLimit in 1..100) { "Ledger limit must be between 1 and 100" }
|
||||
return repository.findDetail(userId, ledgerLimit) ?: throw AdminUserNotFoundException()
|
||||
}
|
||||
|
||||
suspend fun ledger(
|
||||
userId: UUID,
|
||||
limit: Int = 50,
|
||||
cursor: String? = null,
|
||||
): AdminUserLedgerPageDto {
|
||||
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
|
||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
||||
val results = repository.listLedger(userId, limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
val nextCursor = if (hasMore) {
|
||||
val last = items.last()
|
||||
AdminUserLedgerCursorCodec.encode(
|
||||
AdminUserLedgerCursor(
|
||||
createdAt = Instant.parse(last.createdAt),
|
||||
ledgerEntryId = UUID.fromString(last.id),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return AdminUserLedgerPageDto(items, nextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
internal object AdminUserCursorCodec {
|
||||
fun encode(cursor: AdminUserCursor): String {
|
||||
val value = "${cursor.createdAt}|${cursor.userId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String): AdminUserCursor {
|
||||
require(value.length in 1..256) { "User cursor is invalid" }
|
||||
return try {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 2)
|
||||
AdminUserCursor(
|
||||
createdAt = Instant.parse(parts[0]),
|
||||
userId = UUID.fromString(parts[1]),
|
||||
)
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("User cursor is invalid", failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object AdminUserLedgerCursorCodec {
|
||||
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
|
||||
|
||||
fun encode(cursor: AdminUserLedgerCursor): String {
|
||||
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String): AdminUserLedgerCursor {
|
||||
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
|
||||
return try {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 2)
|
||||
AdminUserLedgerCursor(
|
||||
createdAt = Instant.parse(parts[0]),
|
||||
ledgerEntryId = UUID.fromString(parts[1]),
|
||||
)
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,24 @@ data class LedgerEntry(
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
data class ManualCreditGrant(
|
||||
val id: UUID,
|
||||
val operatorId: UUID,
|
||||
val userId: UUID,
|
||||
val amount: Long,
|
||||
val reason: String,
|
||||
val idempotencyKey: String,
|
||||
val ledgerEntryId: UUID,
|
||||
val auditLogId: UUID,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
data class ManualCreditGrantResult(
|
||||
val grant: ManualCreditGrant,
|
||||
val balanceAfter: Long,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Billing metadata only. Provider input, audio, prompts and responses must
|
||||
* never be persisted in a usage record.
|
||||
|
||||
+4
@@ -1,5 +1,6 @@
|
||||
package com.osglab.account.features.credits.repositories
|
||||
|
||||
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.CreditReservation
|
||||
@@ -11,6 +12,8 @@ import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface CreditsRepository {
|
||||
fun accountExists(userId: UUID): Boolean
|
||||
|
||||
fun createAccountIfAbsent(userId: UUID, now: Instant)
|
||||
|
||||
fun lockAccount(userId: UUID): CreditAccount
|
||||
@@ -48,6 +51,7 @@ interface CreditsRepository {
|
||||
interface BillingUnitOfWork {
|
||||
val credits: CreditsRepository
|
||||
val referrals: ReferralsRepository
|
||||
val adminCreditGrants: AdminCreditGrantRepository
|
||||
}
|
||||
|
||||
interface BillingTransactionRunner {
|
||||
|
||||
+16
@@ -1,5 +1,7 @@
|
||||
package com.osglab.account.features.credits.repositories
|
||||
|
||||
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
|
||||
import com.osglab.account.features.admin.grants.repositories.ExposedAdminCreditGrantRepository
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
@@ -31,6 +33,12 @@ import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
private object Accounts : Table("accounts") {
|
||||
val id = varchar("id", 36)
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object CreditAccounts : Table("credit_accounts") {
|
||||
val userId = varchar("user_id", 36)
|
||||
val balance = long("balance")
|
||||
@@ -175,9 +183,17 @@ class ExposedBillingTransactionRunner(
|
||||
private object ExposedBillingUnitOfWork : BillingUnitOfWork {
|
||||
override val credits: CreditsRepository = ExposedCreditsRepository
|
||||
override val referrals: ReferralsRepository = ExposedReferralsRepository
|
||||
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
|
||||
}
|
||||
|
||||
private object ExposedCreditsRepository : CreditsRepository {
|
||||
override fun accountExists(userId: UUID): Boolean =
|
||||
Accounts
|
||||
.selectAll()
|
||||
.where { Accounts.id eq userId.toString() }
|
||||
.limit(1)
|
||||
.singleOrNull() != null
|
||||
|
||||
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
|
||||
CreditAccounts.insertIgnore {
|
||||
it[CreditAccounts.userId] = userId.toString()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.osglab.account.features.credits.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
||||
@@ -11,6 +14,8 @@ import com.osglab.account.features.credits.domain.InsufficientCredits
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.domain.ManualCreditGrant
|
||||
import com.osglab.account.features.credits.domain.ManualCreditGrantResult
|
||||
import com.osglab.account.features.credits.domain.ReservationStatus
|
||||
import com.osglab.account.features.credits.domain.ReservationStateRules
|
||||
import com.osglab.account.features.credits.domain.UsageMeasurement
|
||||
@@ -21,6 +26,7 @@ import com.osglab.account.features.referrals.domain.ReferralBinding
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class ReferralRewardConfig(
|
||||
@@ -52,6 +58,15 @@ interface CreditOperations {
|
||||
idempotencyKey: String,
|
||||
): CreditAccount
|
||||
|
||||
suspend fun grantManual(
|
||||
operatorId: UUID,
|
||||
userId: UUID,
|
||||
credits: Long,
|
||||
reason: String,
|
||||
requestId: String?,
|
||||
idempotencyKey: String,
|
||||
): ManualCreditGrantResult
|
||||
|
||||
suspend fun reserve(
|
||||
userId: UUID,
|
||||
provider: String,
|
||||
@@ -146,6 +161,96 @@ class CreditService(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun grantManual(
|
||||
operatorId: UUID,
|
||||
userId: UUID,
|
||||
credits: Long,
|
||||
reason: String,
|
||||
requestId: String?,
|
||||
idempotencyKey: String,
|
||||
): ManualCreditGrantResult {
|
||||
if (credits <= 0) throw InvalidCreditRequest("Manual grant credits must be positive")
|
||||
val normalizedReason = validatedText(reason, "Manual grant reason", 1, 500)
|
||||
val normalizedRequestId = validatedAdminRequestId(requestId)
|
||||
val key = manualGrantIdempotencyKey(idempotencyKey)
|
||||
return transactions.inTransaction { unit ->
|
||||
if (!unit.credits.accountExists(userId)) {
|
||||
throw CreditNotFound("Manual grant target does not exist")
|
||||
}
|
||||
val now = clock.instant()
|
||||
unit.credits.createAccountIfAbsent(userId, now)
|
||||
val account = unit.credits.lockAccount(userId)
|
||||
unit.adminCreditGrants.findByIdempotencyKey(key)?.let { existing ->
|
||||
requireIdempotentManualGrant(
|
||||
existing,
|
||||
operatorId,
|
||||
userId,
|
||||
credits,
|
||||
normalizedReason,
|
||||
)
|
||||
val ledger = unit.credits.findLedgerEntry(userId, key)
|
||||
?: throw CreditConflict("Manual grant audit exists without its ledger entry")
|
||||
requireIdempotentLedger(
|
||||
ledger,
|
||||
LedgerEntryType.MANUAL_GRANT,
|
||||
credits,
|
||||
existing.id,
|
||||
)
|
||||
if (ledger.id != existing.ledgerEntryId) {
|
||||
throw CreditConflict("Manual grant audit does not match its ledger entry")
|
||||
}
|
||||
return@inTransaction ManualCreditGrantResult(
|
||||
grant = existing,
|
||||
balanceAfter = ledger.balanceAfter,
|
||||
replayed = true,
|
||||
)
|
||||
}
|
||||
ensureUnusedLedgerKey(unit, userId, key)
|
||||
val grantId = newId()
|
||||
val ledgerEntryId = newId()
|
||||
val auditLogId = newId()
|
||||
val updated = applyLedgerDelta(
|
||||
unit = unit,
|
||||
account = account,
|
||||
delta = credits,
|
||||
type = LedgerEntryType.MANUAL_GRANT,
|
||||
key = key,
|
||||
referenceId = grantId,
|
||||
now = now,
|
||||
entryId = ledgerEntryId,
|
||||
)
|
||||
val grant = ManualCreditGrant(
|
||||
id = grantId,
|
||||
operatorId = operatorId,
|
||||
userId = userId,
|
||||
amount = credits,
|
||||
reason = normalizedReason,
|
||||
idempotencyKey = key,
|
||||
ledgerEntryId = ledgerEntryId,
|
||||
auditLogId = auditLogId,
|
||||
createdAt = now,
|
||||
)
|
||||
unit.adminCreditGrants.insertAudit(
|
||||
NewAdminAuditEvent(
|
||||
id = auditLogId,
|
||||
actorOperatorId = operatorId,
|
||||
action = AdminAuditAction.MANUAL_CREDIT_GRANTED,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = "ACCOUNT",
|
||||
targetId = userId.toString(),
|
||||
requestId = normalizedRequestId,
|
||||
occurredAt = now,
|
||||
),
|
||||
)
|
||||
unit.adminCreditGrants.insert(grant)
|
||||
ManualCreditGrantResult(
|
||||
grant = grant,
|
||||
balanceAfter = updated.balance,
|
||||
replayed = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reserve(
|
||||
userId: UUID,
|
||||
provider: String,
|
||||
@@ -503,6 +608,7 @@ class CreditService(
|
||||
key: String,
|
||||
referenceId: UUID?,
|
||||
now: Instant,
|
||||
entryId: UUID = newId(),
|
||||
): CreditAccount {
|
||||
val newBalance = try {
|
||||
Math.addExact(account.balance, delta)
|
||||
@@ -512,7 +618,7 @@ class CreditService(
|
||||
if (newBalance < 0) throw InsufficientCredits(account.balance, -delta)
|
||||
unit.credits.insertLedgerEntry(
|
||||
LedgerEntry(
|
||||
id = newId(),
|
||||
id = entryId,
|
||||
userId = account.userId,
|
||||
type = type,
|
||||
amountDelta = delta,
|
||||
@@ -539,6 +645,22 @@ class CreditService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireIdempotentManualGrant(
|
||||
existing: ManualCreditGrant,
|
||||
expectedOperatorId: UUID,
|
||||
expectedUserId: UUID,
|
||||
expectedAmount: Long,
|
||||
expectedReason: String,
|
||||
) {
|
||||
if (existing.operatorId != expectedOperatorId ||
|
||||
existing.userId != expectedUserId ||
|
||||
existing.amount != expectedAmount ||
|
||||
existing.reason != expectedReason
|
||||
) {
|
||||
throw CreditConflict("Idempotency key was already used with different manual grant parameters")
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePositiveCost(
|
||||
rate: CreditRateVersion,
|
||||
usage: UsageMeasurement,
|
||||
@@ -557,6 +679,38 @@ class CreditService(
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun validatedText(
|
||||
value: String,
|
||||
label: String,
|
||||
minimumLength: Int,
|
||||
maximumLength: Int,
|
||||
): String {
|
||||
val normalized = value.trim()
|
||||
if (normalized.length !in minimumLength..maximumLength) {
|
||||
throw InvalidCreditRequest(
|
||||
"$label must contain $minimumLength to $maximumLength characters",
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun manualGrantIdempotencyKey(value: String): String {
|
||||
val normalized = validatedIdempotencyKey(value)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalized.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
return "internal:manual:$digest"
|
||||
}
|
||||
|
||||
private fun validatedAdminRequestId(value: String?): String? {
|
||||
if (value == null) return null
|
||||
val normalized = value.trim()
|
||||
if (normalized.length !in 1..128 || !ADMIN_REQUEST_ID.matches(normalized)) {
|
||||
throw InvalidCreditRequest("Admin request ID is invalid")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun addOrNull(left: Long, right: Long): Long? =
|
||||
try {
|
||||
Math.addExact(left, right)
|
||||
@@ -569,4 +723,8 @@ class CreditService(
|
||||
val inviterCredits: Long,
|
||||
val inviteeCredits: Long,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.osglab.account.tools
|
||||
|
||||
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption.CREATE_NEW
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Generates one bootstrap operator without printing credentials to stdout.
|
||||
* Both output files are created with owner-only permissions and must not exist.
|
||||
*/
|
||||
object AdminCredentialGenerator {
|
||||
@JvmStatic
|
||||
fun main(arguments: Array<String>) {
|
||||
require(arguments.size == 3) {
|
||||
"Usage: <username> <runtime-env-output> <operator-handoff-output>"
|
||||
}
|
||||
val username = arguments[0].trim().lowercase()
|
||||
require(USERNAME.matches(username)) { "Username must match ${USERNAME.pattern}" }
|
||||
val runtimeOutput = Path.of(arguments[1]).toAbsolutePath()
|
||||
val handoffOutput = Path.of(arguments[2]).toAbsolutePath()
|
||||
require(runtimeOutput != handoffOutput) { "Output paths must be different" }
|
||||
require(Files.notExists(runtimeOutput) && Files.notExists(handoffOutput)) {
|
||||
"Output files must not already exist"
|
||||
}
|
||||
|
||||
val random = SecureRandom()
|
||||
val password = Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(ByteArray(24).also(random::nextBytes))
|
||||
val passwordChars = password.toCharArray()
|
||||
val passwordHash = try {
|
||||
BouncyCastleArgon2idPasswordHasher(secureRandom = random).hash(passwordChars)
|
||||
} finally {
|
||||
passwordChars.fill('\u0000')
|
||||
}
|
||||
val totpSecret = base32(ByteArray(20).also(random::nextBytes))
|
||||
val operatorId = UUID.randomUUID()
|
||||
|
||||
writePrivate(
|
||||
runtimeOutput,
|
||||
"""
|
||||
# One-time admin bootstrap values. Never commit, upload, or screenshot.
|
||||
# After the first successful startup, set ADMIN_BOOTSTRAP_ENABLED=false
|
||||
# and permanently remove all ADMIN_BOOTSTRAP_* credential values.
|
||||
ADMIN_ENABLED=true
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
ADMIN_BOOTSTRAP_OPERATOR_ID=$operatorId
|
||||
ADMIN_BOOTSTRAP_USERNAME=$username
|
||||
ADMIN_BOOTSTRAP_PASSWORD_HASH='$passwordHash'
|
||||
ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=$totpSecret
|
||||
ADMIN_SESSION_HOURS=8
|
||||
ADMIN_MAXIMUM_MANUAL_GRANT=100000
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
writePrivate(
|
||||
handoffOutput,
|
||||
"""
|
||||
OSG 运营后台初始管理员
|
||||
用户名:$username
|
||||
密码:$password
|
||||
TOTP 密钥:$totpSecret
|
||||
认证器 URI:otpauth://totp/OSG%20Admin:$username?secret=$totpSecret&issuer=OSG%20Admin&algorithm=SHA1&digits=6&period=30
|
||||
|
||||
仅保存在受信设备。首次部署成功后,请将密码录入密码管理器,并删除本文件。
|
||||
""".trimIndent() + "\n",
|
||||
)
|
||||
}
|
||||
|
||||
private fun writePrivate(path: Path, content: String) {
|
||||
Files.writeString(path, content, CREATE_NEW)
|
||||
runCatching {
|
||||
Files.setPosixFilePermissions(
|
||||
path,
|
||||
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun base32(bytes: ByteArray): String {
|
||||
val output = StringBuilder((bytes.size * 8 + 4) / 5)
|
||||
var buffer = 0
|
||||
var bufferedBits = 0
|
||||
bytes.forEach { byte ->
|
||||
buffer = (buffer shl 8) or (byte.toInt() and 0xff)
|
||||
bufferedBits += 8
|
||||
while (bufferedBits >= 5) {
|
||||
bufferedBits -= 5
|
||||
output.append(BASE32_ALPHABET[(buffer shr bufferedBits) and 0x1f])
|
||||
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
|
||||
}
|
||||
}
|
||||
if (bufferedBits > 0) {
|
||||
output.append(BASE32_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private val USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
|
||||
private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
}
|
||||
Reference in New Issue
Block a user