Files
OSGAccountServer/src/main/kotlin/com/osglab/account/tools/AdminCredentialGenerator.kt
T
Rocky 1a9c518f96 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.
2026-08-17 15:20:34 +08:00

105 lines
4.1 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
认证器 URIotpauth://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"
}