diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 7be0deb..be3c158 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -24,6 +24,8 @@ import type { CreateOfficialSkillRequest, OfficialSkill, OfficialSkillCatalog, + RevealProviderApiKeyRequest, + RevealProviderApiKeyResponse, ReferralsQuery, ReferralOverview, SessionResponse, @@ -94,6 +96,7 @@ function safeMessage(status: number, code?: string): string { HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求", HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用", PROVIDER_API_KEY_INVALID: "API Key 不符合要求", + PROVIDER_API_KEY_NOT_CONFIGURED: "该 Provider 尚未配置可读取的 API Key", PROVIDER_NOT_FOUND: "不支持该 Provider", RATE_LIMITED: "操作过于频繁,请稍后再试", }; @@ -293,6 +296,18 @@ export const adminApi = { providers: () => request("/providers"), + revealProviderApiKey: ( + providerId: ManagedProviderId, + payload: RevealProviderApiKeyRequest, + ) => + request( + `/providers/${encodeURIComponent(providerId)}/api-key/reveal`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ), + updateProviderApiKey: ( providerId: ManagedProviderId, payload: UpdateProviderApiKeyRequest, diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index 34977b4..c6bc340 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -20,7 +20,8 @@ export type AdminAuditAction = | "CONTENT_HINT_PACK_SAVED" | "CONTENT_HINT_FEED_SETTINGS_UPDATED" | "CONTENT_HINT_FEED_GENERATED" - | "PROVIDER_API_KEY_UPDATED"; + | "PROVIDER_API_KEY_UPDATED" + | "PROVIDER_API_KEY_REVEALED"; export interface SkillLocalization { name: string; @@ -141,6 +142,14 @@ export interface UpdateProviderApiKeyRequest { apiKey: string; } +export interface RevealProviderApiKeyRequest { + totpCode: string; +} + +export interface RevealProviderApiKeyResponse { + apiKey: string; +} + export interface CursorPageQuery { cursor?: string; limit?: number; diff --git a/admin-web/src/features/providers/providers-page.tsx b/admin-web/src/features/providers/providers-page.tsx index 65a8d86..766a204 100644 --- a/admin-web/src/features/providers/providers-page.tsx +++ b/admin-web/src/features/providers/providers-page.tsx @@ -1,4 +1,4 @@ -import { KeyRound, RefreshCw, ServerCog } from "lucide-react"; +import { Eye, EyeOff, KeyRound, RefreshCw, ServerCog } from "lucide-react"; import { useCallback, useEffect, useState, type FormEvent } from "react"; import { toast } from "sonner"; import { adminApi, ApiError } from "../../api/client"; @@ -11,6 +11,7 @@ import { Badge, Button, Card, + Dialog, ErrorState, Input, LoadingState, @@ -74,7 +75,7 @@ export function ProvidersPage() { void load()}> @@ -89,7 +90,7 @@ export function ProvidersPage() {

- 保存后无法从页面读取原 Key。请先确认新 Key 已启用且权限正确;操作会写入审计日志,但不会记录密钥内容。 + 查看当前 Key 需要再次验证动态验证码;查看与替换都会写入审计日志,但不会记录密钥内容。

@@ -124,8 +125,51 @@ function ProviderCard({ onUpdated: (status: ManagedProviderStatus) => void; }) { const [apiKey, setApiKey] = useState(""); + const [currentApiKey, setCurrentApiKey] = useState(); + const [revealOpen, setRevealOpen] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [revealError, setRevealError] = useState(""); + const [revealing, setRevealing] = useState(false); const [saving, setSaving] = useState(false); + function toggleCurrentApiKey() { + if (currentApiKey) { + setCurrentApiKey(undefined); + return; + } + setTotpCode(""); + setRevealError(""); + setRevealOpen(true); + } + + function handleRevealOpenChange(open: boolean) { + setRevealOpen(open); + if (!open) { + setTotpCode(""); + setRevealError(""); + } + } + + async function reveal(event: FormEvent) { + event.preventDefault(); + if (!/^\d{6}$/.test(totpCode)) { + setRevealError("请输入 6 位动态验证码"); + return; + } + + setRevealing(true); + setRevealError(""); + try { + const response = await adminApi.revealProviderApiKey(providerId, { totpCode }); + setCurrentApiKey(response.apiKey); + handleRevealOpenChange(false); + } catch (error) { + setRevealError(error instanceof ApiError ? error.message : "当前 API Key 读取失败"); + } finally { + setRevealing(false); + } + } + async function submit(event: FormEvent) { event.preventDefault(); const normalized = apiKey.trim(); @@ -143,6 +187,7 @@ function ProviderCard({ apiKey: normalized, }); setApiKey(""); + setCurrentApiKey((current) => (current ? normalized : undefined)); onUpdated(updated); toast.success(`${name} API Key 已替换`); } catch (error) { @@ -179,6 +224,48 @@ function ProviderCard({ +
+ +
+ + +
+

+ 点击眼睛并通过动态验证码验证后显示;再次点击或离开页面时隐藏。 +

+
+
void submit(event)}>

- 留空不会修改配置。出于安全考虑,当前 Key 不会显示。 + 留空不会修改配置。若当前 Key 已显示,替换成功后会同步显示新 Key。

+ + +
void reveal(event)} noValidate> + + {revealError ? ( +

+ {revealError} +

+ ) : null} +
+ + +
+
+
); } diff --git a/admin-web/src/test/client.test.ts b/admin-web/src/test/client.test.ts index 3398192..c52fac0 100644 --- a/admin-web/src/test/client.test.ts +++ b/admin-web/src/test/client.test.ts @@ -302,4 +302,28 @@ describe("adminApi", () => { expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider"); expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" })); }); + + it("Provider API Key 查看请求携带 CSRF 且只提交动态验证码", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ apiKey: "current-provider-key" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + setCsrfToken("csrf-reveal"); + + const result = await adminApi.revealProviderApiKey("volcengine", { + totpCode: "123456", + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/v1/admin/providers/volcengine/api-key/reveal", + ); + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(request.method).toBe("POST"); + expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-reveal"); + expect(request.body).toBe(JSON.stringify({ totpCode: "123456" })); + expect(result.apiKey).toBe("current-provider-key"); + }); }); diff --git a/admin-web/src/test/providers.test.tsx b/admin-web/src/test/providers.test.tsx index eb95d40..6e0c4fd 100644 --- a/admin-web/src/test/providers.test.tsx +++ b/admin-web/src/test/providers.test.tsx @@ -12,7 +12,7 @@ afterEach(() => { }); describe("Provider 配置", () => { - it("仅展示配置状态,不会回显现有 API Key", async () => { + it("默认仅展示配置状态,不会回显现有 API Key", async () => { mockSession(); mockProviders(); window.location.hash = "#/providers"; @@ -27,6 +27,34 @@ describe("Provider 配置", () => { expect(document.body.textContent).not.toContain("existing-secret"); }); + it("通过动态验证码显示当前 Key,并可再次点击眼睛隐藏", async () => { + mockSession(); + mockProviders(); + const reveal = vi.spyOn(adminApi, "revealProviderApiKey").mockResolvedValue({ + apiKey: "existing-secret", + }); + window.location.hash = "#/providers"; + render(); + + const currentKey = await screen.findByLabelText("DeepSeek 当前 API Key"); + expect(currentKey).toHaveProperty("value", "••••••••••••••••"); + await userEvent.click( + screen.getByRole("button", { name: "显示 DeepSeek 当前 API Key" }), + ); + await userEvent.type(screen.getByLabelText("动态验证码"), "123456"); + await userEvent.click(screen.getByRole("button", { name: "验证并显示" })); + + await waitFor(() => + expect(reveal).toHaveBeenCalledWith("deepseek", { totpCode: "123456" }), + ); + await waitFor(() => expect(currentKey).toHaveProperty("value", "existing-secret")); + + await userEvent.click( + screen.getByRole("button", { name: "隐藏 DeepSeek 当前 API Key" }), + ); + expect(currentKey).toHaveProperty("value", "••••••••••••••••"); + }); + it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => { mockSession(); mockProviders(); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ac5b702..6731e21 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -978,6 +978,62 @@ paths: "400": { description: API key is blank, multiline, oversized, or malformed } "403": { description: SUPER_ADMIN role and valid CSRF are required } "404": { description: Provider is not supported } + /v1/admin/providers/{providerId}/api-key/reveal: + post: + security: + - adminMtls: [] + adminSession: [] + summary: Reveal the effective provider API key after a TOTP step-up + description: | + Returns the runtime override or environment-backed API key for the current + SUPER_ADMIN request only. Every attempt is rate-limited and audited without + recording the TOTP code or API key. Successful responses are non-cacheable. + parameters: + - name: providerId + in: path + required: true + schema: { type: string, enum: [deepseek, volcengine] } + - $ref: "#/components/parameters/AdminCsrf" + - name: X-Request-ID + in: header + required: false + schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" } + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [totpCode] + properties: + totpCode: + type: string + pattern: "^[0-9]{6}$" + writeOnly: true + responses: + "200": + description: Effective API key revealed for this response only + headers: + Cache-Control: + schema: { type: string, const: no-store } + content: + application/json: + schema: + type: object + additionalProperties: false + required: [apiKey] + properties: + apiKey: + type: string + minLength: 1 + maxLength: 4096 + format: password + readOnly: true + "401": { description: TOTP verification failed or session is invalid } + "403": { description: SUPER_ADMIN role and valid CSRF are required } + "404": { description: Provider or a revealable API key was not found } + "429": { description: Step-up attempts are rate-limited } /v1/admin/overview: get: security: @@ -1358,6 +1414,7 @@ paths: - CONTENT_HINT_FEED_SETTINGS_UPDATED - CONTENT_HINT_FEED_GENERATED - PROVIDER_API_KEY_UPDATED + - PROVIDER_API_KEY_REVEALED - name: result in: query schema: { type: string, enum: [success, rejected] } diff --git a/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt index 6fca793..404e5ff 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt @@ -120,6 +120,7 @@ enum class AdminAuditAction { CONTENT_HINT_FEED_SETTINGS_UPDATED, CONTENT_HINT_FEED_GENERATED, PROVIDER_API_KEY_UPDATED, + PROVIDER_API_KEY_REVEALED, } enum class AdminAuditOutcome { @@ -127,6 +128,12 @@ enum class AdminAuditOutcome { DENIED, } +enum class AdminStepUpResult { + VERIFIED, + INVALID_TOTP, + LOCKED, +} + data class NewAdminAuditEvent( val id: UUID = UUID.randomUUID(), val actorOperatorId: UUID?, diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index d7991ba..58e4081 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -13,6 +13,7 @@ import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminStepUpResult import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.services.AdminAuditCursorException import com.osglab.account.features.admin.services.AdminAuditService @@ -63,6 +64,7 @@ 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.header import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.delete @@ -191,6 +193,55 @@ fun Route.adminApiRoutes( call.respond(service.listStatuses()) } + rateLimit(ADMIN_AUTH_RATE_LIMIT) { + post("/providers/{providerId}/api-key/reveal") { + val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post + if (principal.role != AdminRole.SUPER_ADMIN) { + call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION")) + return@post + } + val provider = GatewayCredentialProvider.fromProviderId( + call.parameters["providerId"], + ) ?: run { + call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND")) + return@post + } + val request = call.receiveAdminRequest() ?: return@post + when ( + authService.verifyStepUpTotp( + principal = principal, + totpCode = request.totpCode, + action = AdminAuditAction.PROVIDER_API_KEY_REVEALED, + targetType = "PROVIDER", + targetId = provider.providerId, + requestId = call.request.header("X-Request-ID"), + ) + ) { + AdminStepUpResult.LOCKED -> { + call.respond(HttpStatusCode.TooManyRequests, AdminErrorResponse("RATE_LIMITED")) + return@post + } + + AdminStepUpResult.INVALID_TOTP -> { + call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("INVALID_TOTP")) + return@post + } + + AdminStepUpResult.VERIFIED -> Unit + } + val revealed = service.revealApiKey(provider) ?: run { + call.respond( + HttpStatusCode.NotFound, + AdminErrorResponse("PROVIDER_API_KEY_NOT_CONFIGURED"), + ) + return@post + } + call.response.header(HttpHeaders.CacheControl, "no-store") + call.response.header(HttpHeaders.Pragma, "no-cache") + call.respond(ProviderApiKeyRevealResponse(revealed.value)) + } + } + put("/providers/{providerId}/api-key") { val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put if (principal.role != AdminRole.SUPER_ADMIN) { @@ -1146,6 +1197,16 @@ private data class AdminGrantResponse(val transactionId: String, val balanceAfte @Serializable private data class ProviderApiKeyUpdateRequest(val apiKey: String) +@Serializable +private class ProviderApiKeyRevealRequest(val totpCode: String) { + override fun toString(): String = "ProviderApiKeyRevealRequest(totpCode=[REDACTED])" +} + +@Serializable +private class ProviderApiKeyRevealResponse(val apiKey: String) { + override fun toString(): String = "ProviderApiKeyRevealResponse(apiKey=[REDACTED])" +} + @Serializable private data class AdminOperatorCreateRequest( val username: String, diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt index 4559fc1..5595439 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuthService.kt @@ -10,6 +10,7 @@ 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.AdminStepUpResult import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminSession import com.osglab.account.features.admin.repositories.AdminRepository @@ -155,6 +156,50 @@ class AdminAuthService( ) } + suspend fun verifyStepUpTotp( + principal: AdminPrincipal, + totpCode: String, + action: AdminAuditAction, + targetType: String, + targetId: String, + requestId: String? = null, + ): AdminStepUpResult { + val now = clock.instant() + val operator = repository.findOperatorForAuthentication(principal.normalizedUsername) + val result = when { + operator == null || + operator.id != principal.operatorId || + operator.disabledAt != null -> AdminStepUpResult.INVALID_TOTP + + operator.lockState.isLockedAt(now) -> AdminStepUpResult.LOCKED + + verifyTotp( + operator.id, + operator.encryptedTotpSecret, + totpCode, + now, + ) != null -> AdminStepUpResult.VERIFIED + + else -> AdminStepUpResult.INVALID_TOTP + } + repository.appendAudit( + NewAdminAuditEvent( + actorOperatorId = principal.operatorId, + action = action, + outcome = if (result == AdminStepUpResult.VERIFIED) { + AdminAuditOutcome.SUCCESS + } else { + AdminAuditOutcome.DENIED + }, + targetType = targetType, + targetId = targetId, + requestId = validateRequestId(requestId), + occurredAt = now, + ), + ) + return result + } + private suspend fun failAuthentication( operatorId: UUID, now: Instant, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt index 504e461..684dff3 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialService.kt @@ -9,6 +9,14 @@ import java.util.UUID class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key") +class RevealedProviderApiKey(val value: String) { + init { + require(value.isNotBlank()) + } + + override fun toString(): String = "RevealedProviderApiKey([REDACTED])" +} + class GatewayCredentialService( private val repository: GatewayCredentialRepository, private val resolver: DatabaseProviderApiKeyResolver, @@ -18,6 +26,9 @@ class GatewayCredentialService( suspend fun listStatuses(): List = GatewayCredentialProvider.entries.map { resolver.status(it) } + suspend fun revealApiKey(provider: GatewayCredentialProvider): RevealedProviderApiKey? = + resolver.resolve(provider)?.let(::RevealedProviderApiKey) + suspend fun updateApiKey( provider: GatewayCredentialProvider, apiKey: String, diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index 6cb4485..4228a47 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -420,6 +420,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf( "/v1/admin/auth/logout", "/v1/admin/providers", "/v1/admin/providers/{providerId}/api-key", + "/v1/admin/providers/{providerId}/api-key/reveal", "/v1/admin/overview", "/v1/admin/referrals", "/v1/admin/analytics", diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index 151519e..2ab9b05 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -3,8 +3,10 @@ package com.osglab.account.features.admin.routes import com.osglab.account.config.AdminConfig import com.osglab.account.config.AppConfig import com.osglab.account.features.admin.grants.services.AdminGrantService +import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminStepUpResult import com.osglab.account.features.admin.services.AdminAuditService import com.osglab.account.features.admin.services.AdminAuthService import com.osglab.account.features.admin.services.AdminOperatorService @@ -25,10 +27,12 @@ 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 com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider import com.osglab.account.features.gateway.credentials.GatewayCredentialService import com.osglab.account.features.gateway.credentials.GatewayCredentialSource import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException +import com.osglab.account.features.gateway.credentials.RevealedProviderApiKey import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldNotContain @@ -520,6 +524,77 @@ class AdminRoutesTest { body shouldNotContain "secret-runtime-key" } + @Test + fun `super admin can reveal provider API key after TOTP step-up`() = testApplication { + val authService = mockk() + val credentialService = mockk() + coEvery { + authService.verifyStepUpTotp(any(), any(), any(), any(), any(), any()) + } returns AdminStepUpResult.VERIFIED + coEvery { + credentialService.revealApiKey(GatewayCredentialProvider.DEEPSEEK) + } returns RevealedProviderApiKey("secret-runtime-key") + application { + installAdminTestRoutes( + authService = authService, + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + credentialService = credentialService, + ) + } + + val response = client.post("/v1/admin/providers/deepseek/api-key/reveal") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + contentType(ContentType.Application.Json) + setBody("""{"totpCode":"123456"}""") + } + + response.status shouldBe HttpStatusCode.OK + response.headers[HttpHeaders.CacheControl] shouldBe "no-store" + response.bodyAsText() shouldBe """{"apiKey":"secret-runtime-key"}""" + coVerify { + authService.verifyStepUpTotp( + any(), + "123456", + AdminAuditAction.PROVIDER_API_KEY_REVEALED, + "PROVIDER", + "deepseek", + any(), + ) + } + } + + @Test + fun `invalid step-up TOTP never reveals provider API key`() = testApplication { + val authService = mockk() + val credentialService = mockk(relaxed = true) + coEvery { + authService.verifyStepUpTotp(any(), any(), any(), any(), any(), any()) + } returns AdminStepUpResult.INVALID_TOTP + application { + installAdminTestRoutes( + authService = authService, + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + credentialService = credentialService, + ) + } + + val response = client.post("/v1/admin/providers/deepseek/api-key/reveal") { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Origin, "https://account.osglab.com") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + header("X-CSRF-Token", "csrf-token") + contentType(ContentType.Application.Json) + setBody("""{"totpCode":"000000"}""") + } + + response.status shouldBe HttpStatusCode.Unauthorized + response.bodyAsText() shouldBe """{"code":"INVALID_TOTP"}""" + coVerify(exactly = 0) { credentialService.revealApiKey(any()) } + } + @Test fun `non super admin cannot update provider API keys`() = testApplication { val credentialService = mockk(relaxed = true) diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt index c998f21..3881a96 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuthServiceTest.kt @@ -8,6 +8,9 @@ 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.AdminRole +import com.osglab.account.features.admin.models.AdminStepUpResult import com.osglab.account.features.admin.security.AdminPasswordHasher import com.osglab.account.features.admin.security.HmacTotpVerifier import io.kotest.core.spec.style.FunSpec @@ -140,6 +143,49 @@ class AdminAuthServiceTest : FunSpec({ (result is AdminLoginResult.Authenticated) shouldBe true fixture.repository.audits.single().requestId shouldBe null } + + test("step-up accepts the current TOTP after login and audits the reveal") { + val fixture = authFixture() + val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant()) + val login = fixture.service.login( + username = "admin@example.com", + password = CORRECT_PASSWORD.toCharArray(), + totpCode = code, + ) + (login is AdminLoginResult.Authenticated) shouldBe true + + val result = fixture.service.verifyStepUpTotp( + principal = fixture.principal(), + totpCode = code, + action = AdminAuditAction.PROVIDER_API_KEY_REVEALED, + targetType = "PROVIDER", + targetId = "deepseek", + requestId = "reveal-1", + ) + + result shouldBe AdminStepUpResult.VERIFIED + fixture.repository.audits.last().let { + it.action shouldBe AdminAuditAction.PROVIDER_API_KEY_REVEALED + it.outcome shouldBe AdminAuditOutcome.SUCCESS + it.targetId shouldBe "deepseek" + it.requestId shouldBe "reveal-1" + } + } + + test("invalid step-up TOTP is denied and audited") { + val fixture = authFixture() + + val result = fixture.service.verifyStepUpTotp( + principal = fixture.principal(), + totpCode = "000000", + action = AdminAuditAction.PROVIDER_API_KEY_REVEALED, + targetType = "PROVIDER", + targetId = "volcengine", + ) + + result shouldBe AdminStepUpResult.INVALID_TOTP + fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED + } }) private data class AuthFixture( @@ -151,6 +197,13 @@ private data class AuthFixture( val lockPolicy: AdminLoginLockPolicy, val tokenGenerator: SecureTokenGenerator, ) { + fun principal() = AdminPrincipal( + operatorId = repository.operatorId, + sessionId = UUID.randomUUID(), + normalizedUsername = repository.username, + role = AdminRole.SUPER_ADMIN, + ) + fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService( repository = repository, passwordHasher = hasher, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt index 7487798..c0949db 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/credentials/GatewayCredentialServiceTest.kt @@ -57,6 +57,10 @@ class GatewayCredentialServiceTest : StringSpec({ resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine" resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe GatewayCredentialSource.RUNTIME_OVERRIDE + val revealed = GatewayCredentialService(repository, resolver, encryptor) + .revealApiKey(GatewayCredentialProvider.VOLCENGINE) + revealed?.value shouldBe "runtime-volcengine" + revealed.toString() shouldNotContain "runtime-volcengine" } "two updates make new resolutions use the latest encrypted key" {