diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 6310908..06ac4dc 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -11,15 +11,18 @@ import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; import { buildProfileResponse } from '../utils/profile-response'; -import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; +import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp'; +import { + getYubicoCredentials, + initializeYubicoCredentialsOnce, + replaceYubicoCredentials, +} from '../services/yubico-config'; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_YUBIKEY = 3; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; -const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; -const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; // CONTRACT: // users.master_password_hash is server-side login verification only. It does @@ -201,31 +204,6 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined { return typeof current === 'number' ? current : undefined; } -async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise { - const fromEnv = yubicoCredentialsFromEnv(env); - if (fromEnv) return fromEnv; - const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim(); - if (!clientId) return null; - const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim(); - return { clientId, secretKey }; -} - -async function ensureStoredYubicoCredentials( - storage: StorageService, - env: Env, - email: string, - otp: string -): Promise { - const existing = await getStoredYubicoCredentials(storage, env); - if (existing) return existing; - - const credentials = await requestYubicoApiCredentials(email, otp); - if (!credentials) return null; - await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); - await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); - return credentials; -} - async function readRequestBody(request: Request): Promise> { const contentType = request.headers.get('content-type') || ''; if (contentType.includes('application/x-www-form-urlencoded')) { @@ -815,12 +793,19 @@ function deviceVerificationSettingsResponse(_user: User): Record> { - const credentials = await getStoredYubicoCredentials(storage, env); + void storage; + const credentials = await getYubicoCredentials(env.DB); + const canManageCredentials = user.role === 'admin' && user.status === 'active'; return { ...yubiKeyResponse(user), YubicoConfigured: !!credentials?.clientId, - YubicoClientId: credentials?.clientId ?? '', - YubicoSecretKey: credentials?.secretKey ?? '', + YubicoCanManage: canManageCredentials, + ...(canManageCredentials + ? { + YubicoClientId: credentials?.clientId ?? '', + YubicoSecretKey: credentials?.secretKey ?? '', + } + : {}), }; } @@ -1013,7 +998,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user readBodyString(body, ['key5', 'Key5']), ]; const publicIds: Array = []; - let credentials = await getStoredYubicoCredentials(storage, env); + let credentials = await getYubicoCredentials(env.DB); let apiKeyBootstrapOtpIndex: number | null = null; for (const key of keys) { const trimmed = key.trim(); @@ -1028,9 +1013,10 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user continue; } if (!credentials) { - credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed); - if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); - apiKeyBootstrapOtpIndex = publicIds.length; + const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed); + if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400); + credentials = initialized.credentials; + if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length; } if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) { return errorResponse('Invalid YubiKey OTP.', 400); @@ -1071,6 +1057,7 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env const auth = new AuthService(env); const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); + if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403); let body: Record; try { @@ -1085,10 +1072,18 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim(); const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim(); - if (!clientId) return errorResponse('Yubico Client ID is required.', 400); + if (!clientId || !secretKey) return errorResponse('Yubico Client ID and Secret Key are required.', 400); - await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId); - await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey); + await replaceYubicoCredentials(env.DB, { clientId, secretKey }); + await writeAuditEvent(storage, { + actorUserId: user.id, + action: 'system.yubico.credentials.update', + category: 'security', + level: 'security', + targetType: 'system', + targetId: 'yubico', + metadata: auditRequestMetadata(request), + }); return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); } @@ -1113,11 +1108,42 @@ export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, en const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim(); if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400); - const credentials = await requestYubicoApiCredentials(user.email, otp); - if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); + const existing = await getYubicoCredentials(env.DB); + if (user.role !== 'admin' && existing) { + return errorResponse('Yubico validation credentials are already configured.', 403); + } - await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); - await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); + let credentials; + if (user.role === 'admin') { + credentials = await requestYubicoApiCredentials(user.email, otp); + if (!credentials?.clientId || !credentials.secretKey) { + return errorResponse('Unable to initialize Yubico validation credentials.', 400); + } + await replaceYubicoCredentials(env.DB, credentials); + } else { + const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, otp); + if (!initialized?.created) { + return errorResponse( + initialized?.credentials + ? 'Yubico validation credentials are already configured.' + : 'Unable to initialize Yubico validation credentials.', + initialized?.credentials ? 403 : 400 + ); + } + credentials = initialized.credentials; + } + + await writeAuditEvent(storage, { + actorUserId: user.id, + action: user.role === 'admin' + ? 'system.yubico.credentials.reconfigure' + : 'system.yubico.credentials.initialize', + category: 'security', + level: 'security', + targetType: 'system', + targetId: 'yubico', + metadata: auditRequestMetadata(request), + }); return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 0816182..69d3e1c 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -25,7 +25,8 @@ import { import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { constantTimeEquals, verifyApiKey } from '../utils/api-key'; -import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; +import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp'; +import { getYubicoCredentials, initializeYubicoCredentialsOnce } from '../services/yubico-config'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -34,8 +35,6 @@ const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; -const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; -const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows // the official Identity provider enum (RecoveryCode = 8), while request parsing remains // compatible with older/local provider values. @@ -163,15 +162,6 @@ async function loginRateLimitKey(clientIdentifier: string, grantType: string, su return `${clientIdentifier}:login:${grantType}:${subjectHash}`; } -async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise { - const fromEnv = yubicoCredentialsFromEnv(env); - if (fromEnv) return fromEnv; - const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim(); - if (!clientId) return null; - const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim(); - return { clientId, secretKey }; -} - function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { const isHttps = new URL(request.url).protocol === 'https:'; const parts = [ @@ -507,8 +497,17 @@ export async function handleToken(request: Request, env: Env): Promise if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } - const credentials = await getStoredYubicoCredentials(storage, env); - if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { + let credentials = await getYubicoCredentials(env.DB); + let initializedWithCurrentOtp = false; + if (!credentials) { + const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, normalizedTwoFactorToken); + if (!initialized) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } + credentials = initialized.credentials; + initializedWithCurrentOtp = initialized.created; + } + if (!initializedWithCurrentOtp && !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) { diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 1a8a0e2..6752109 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -2,6 +2,7 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate'; import type { Env } from '../types'; import { APP_VERSION } from '../../shared/app-version'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; +import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config'; import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto'; import { getAttachmentObjectKey, @@ -111,7 +112,7 @@ function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] { const sanitized: SqlRow[] = []; for (const row of rows) { const key = String(row.key || '').trim(); - if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue; + if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY || key === YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY) continue; if (key === BACKUP_SETTINGS_CONFIG_KEY) { const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null); diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index c223d9a..bc564e0 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -1,6 +1,7 @@ import type { Env, User } from '../types'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; +import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config'; import { type BackupManifestAttachmentBlob, type BackupPayload, @@ -276,7 +277,9 @@ async function prepareImportedConfigRows( configRows: SqlRow[], userRows: SqlRow[] ): Promise { - let nextConfigRows = cloneRows(configRows || []); + let nextConfigRows = cloneRows(configRows || []).filter( + (row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY + ); const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY); const normalizedBackupSettings = await normalizeImportedBackupSettingsValue( typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null, diff --git a/src/services/yubico-config.ts b/src/services/yubico-config.ts new file mode 100644 index 0000000..8866597 --- /dev/null +++ b/src/services/yubico-config.ts @@ -0,0 +1,97 @@ +import { + requestYubicoApiCredentials, + type YubicoApiCredentials, +} from '../utils/yubico-otp'; + +export const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; +export const YUBICO_SECRET_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; +export const YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY = 'yubico.bootstrap.claim.v1'; + +const YUBICO_BOOTSTRAP_CLAIM_TTL_MS = 2 * 60 * 1000; + +export interface YubicoCredentialInitializationResult { + credentials: YubicoApiCredentials; + created: boolean; +} + +export async function getYubicoCredentials(db: D1Database): Promise { + const result = await db + .prepare('SELECT key, value FROM config WHERE key IN (?, ?)') + .bind(YUBICO_CLIENT_ID_CONFIG_KEY, YUBICO_SECRET_KEY_CONFIG_KEY) + .all<{ key: string; value: string }>(); + const values = new Map((result.results || []).map((row) => [row.key, String(row.value || '').trim()])); + const clientId = values.get(YUBICO_CLIENT_ID_CONFIG_KEY) || ''; + const secretKey = values.get(YUBICO_SECRET_KEY_CONFIG_KEY) || ''; + return clientId && secretKey ? { clientId, secretKey } : null; +} + +export async function replaceYubicoCredentials( + db: D1Database, + credentials: YubicoApiCredentials +): Promise { + const clientId = String(credentials.clientId || '').trim(); + const secretKey = String(credentials.secretKey || '').trim(); + if (!clientId || !secretKey) throw new Error('Yubico credentials are incomplete'); + await db.batch([ + db.prepare( + 'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).bind(YUBICO_CLIENT_ID_CONFIG_KEY, clientId), + db.prepare( + 'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).bind(YUBICO_SECRET_KEY_CONFIG_KEY, secretKey), + ]); +} + +async function acquireBootstrapClaim(db: D1Database): Promise { + const now = Date.now(); + await db + .prepare('DELETE FROM config WHERE key = ? AND CAST(value AS INTEGER) < ?') + .bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, now) + .run(); + const claim = `${now + YUBICO_BOOTSTRAP_CLAIM_TTL_MS}:${crypto.randomUUID()}`; + const result = await db + .prepare('INSERT OR IGNORE INTO config(key, value) VALUES(?, ?)') + .bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim) + .run(); + return (result.meta.changes ?? 0) > 0 ? claim : null; +} + +async function releaseBootstrapClaim(db: D1Database, claim: string): Promise { + await db + .prepare('DELETE FROM config WHERE key = ? AND value = ?') + .bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim) + .run(); +} + +export async function initializeYubicoCredentialsOnce( + db: D1Database, + email: string, + otp: string +): Promise { + const existing = await getYubicoCredentials(db); + if (existing) return { credentials: existing, created: false }; + + const claim = await acquireBootstrapClaim(db); + if (!claim) { + const concurrentlyCreated = await getYubicoCredentials(db); + return concurrentlyCreated ? { credentials: concurrentlyCreated, created: false } : null; + } + + try { + const rechecked = await getYubicoCredentials(db); + if (rechecked) return { credentials: rechecked, created: false }; + + const issued = await requestYubicoApiCredentials(email, otp); + if (!issued?.clientId || !issued.secretKey) return null; + + const configuredDuringRequest = await getYubicoCredentials(db); + if (configuredDuringRequest) { + return { credentials: configuredDuringRequest, created: false }; + } + + await replaceYubicoCredentials(db, issued); + return { credentials: issued, created: true }; + } finally { + await releaseBootstrapClaim(db, claim).catch(() => undefined); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 2d94570..aafb86b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,11 +14,7 @@ export interface Env { WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_NAME?: string; WEBAUTHN_ALLOWED_ORIGINS?: string; - YUBICO_CLIENT_ID?: string; - YUBICO_SECRET_KEY?: string; YUBICO_VALIDATION_URLS?: string; - 'globalSettings__yubico__clientId'?: string; - 'globalSettings__yubico__key'?: string; 'globalSettings__yubico__validationUrls'?: string; } diff --git a/src/utils/yubico-otp.ts b/src/utils/yubico-otp.ts index cd2e6f1..38c4c05 100644 --- a/src/utils/yubico-otp.ts +++ b/src/utils/yubico-otp.ts @@ -48,12 +48,6 @@ export function isYubiKeyEnabled(user: User): boolean { return userYubiKeyPublicIds(user).length > 0; } -export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null { - const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim(); - const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim(); - return clientId ? { clientId, secretKey } : null; -} - function randomNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); @@ -143,27 +137,25 @@ export async function requestYubicoApiCredentials(email: string, otpInput: strin export async function verifyYubicoOtp( env: Env, otpInput: string, - credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env) + credentials: YubicoApiCredentials | null ): Promise { const otp = normalizeYubiKeyOtp(otpInput); if (!isYubiKeyOtp(otp)) return false; const clientId = String(credentials?.clientId || '').trim(); - if (!clientId) return false; + const secretKey = String(credentials?.secretKey || '').trim(); + if (!clientId || !secretKey) return false; const nonce = randomNonce(); - const secretKey = String(credentials?.secretKey || '').trim(); const params = new URLSearchParams({ id: clientId, nonce, otp, }); - if (secretKey) { - try { - params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); - } catch { - return false; - } + try { + params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); + } catch { + return false; } for (const baseUrl of validationUrls(env)) { @@ -172,14 +164,12 @@ export async function verifyYubicoOtp( if (!response.ok) continue; const parsed = parseYubicoResponse(await response.text()); if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; - if (secretKey) { - if (!parsed.h) continue; - const signedParams = new URLSearchParams(); - for (const [key, value] of Object.entries(parsed)) { - if (key !== 'h') signedParams.set(key, value); - } - if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue; + if (!parsed.h) continue; + const signedParams = new URLSearchParams(); + for (const [key, value] of Object.entries(parsed)) { + if (key !== 'h') signedParams.set(key, value); } + if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue; return true; } catch { continue; diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index d7cc65f..53231ba 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -144,6 +144,7 @@ export default function SettingsPage(props: SettingsPageProps) { const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS); const [yubiKeyNfc, setYubiKeyNfc] = useState(false); const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false); + const [yubiKeyYubicoCanManage, setYubiKeyYubicoCanManage] = useState(false); const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState(''); const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState(''); const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState(''); @@ -340,6 +341,7 @@ export default function SettingsPage(props: SettingsPageProps) { setYubiKeyStoredKeys(settings.keys); setYubiKeyNfc(settings.nfc); setYubiKeyYubicoConfigured(settings.yubicoConfigured); + setYubiKeyYubicoCanManage(settings.yubicoCanManage); setYubiKeyYubicoClientId(settings.yubicoClientId); setYubiKeyYubicoSecretKey(settings.yubicoSecretKey); } @@ -352,6 +354,7 @@ export default function SettingsPage(props: SettingsPageProps) { setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS); setYubiKeyNfc(false); setYubiKeyYubicoConfigured(false); + setYubiKeyYubicoCanManage(false); setYubiKeyYubicoClientId(''); setYubiKeyYubicoSecretKey(''); setYubiKeyBootstrapOtp(''); @@ -1010,8 +1013,7 @@ export default function SettingsPage(props: SettingsPageProps) { )} - {yubiKeyYubicoConfigured && ( - <> + {yubiKeyYubicoConfigured && yubiKeyYubicoCanManage && (

{t('txt_yubikey_validation_credentials')}

@@ -1055,7 +1057,10 @@ export default function SettingsPage(props: SettingsPageProps) {
)}
+ )} + {yubiKeyYubicoConfigured && ( + <>
  1. {t('txt_yubikey_plug_in')}
  2. {t('txt_yubikey_select_empty_field')}
  3. diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index e411371..2bfe4a7 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -687,6 +687,7 @@ function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings { ], nfc: !!(raw?.nfc ?? raw?.Nfc), yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured), + yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage), yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''), yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''), }; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 1037230..9799b89 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -423,6 +423,7 @@ export interface YubiKeyOtpSettings { keys: [string, string, string, string, string]; nfc: boolean; yubicoConfigured: boolean; + yubicoCanManage: boolean; yubicoClientId: string; yubicoSecretKey: string; }