mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
feat(yubico): refactor Yubico credential management and enhance settings UI
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<SqlRow[]> {
|
||||
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,
|
||||
|
||||
@@ -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<YubicoApiCredentials | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<YubicoCredentialInitializationResult | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user