mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
feat(yubico): refactor Yubico credential management and enhance settings UI
This commit is contained in:
+68
-42
@@ -11,15 +11,18 @@ import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
|||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
import { buildProfileResponse } from '../utils/profile-response';
|
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_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
|
||||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
|
||||||
|
|
||||||
// CONTRACT:
|
// CONTRACT:
|
||||||
// users.master_password_hash is server-side login verification only. It does
|
// 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;
|
return typeof current === 'number' ? current : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
|
||||||
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<YubicoApiCredentials | null> {
|
|
||||||
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<Record<string, unknown>> {
|
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
@@ -815,12 +793,19 @@ function deviceVerificationSettingsResponse(_user: User): Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
void storage;
|
||||||
|
const credentials = await getYubicoCredentials(env.DB);
|
||||||
|
const canManageCredentials = user.role === 'admin' && user.status === 'active';
|
||||||
return {
|
return {
|
||||||
...yubiKeyResponse(user),
|
...yubiKeyResponse(user),
|
||||||
YubicoConfigured: !!credentials?.clientId,
|
YubicoConfigured: !!credentials?.clientId,
|
||||||
YubicoClientId: credentials?.clientId ?? '',
|
YubicoCanManage: canManageCredentials,
|
||||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
...(canManageCredentials
|
||||||
|
? {
|
||||||
|
YubicoClientId: credentials?.clientId ?? '',
|
||||||
|
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,7 +998,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
|
|||||||
readBodyString(body, ['key5', 'Key5']),
|
readBodyString(body, ['key5', 'Key5']),
|
||||||
];
|
];
|
||||||
const publicIds: Array<string | null> = [];
|
const publicIds: Array<string | null> = [];
|
||||||
let credentials = await getStoredYubicoCredentials(storage, env);
|
let credentials = await getYubicoCredentials(env.DB);
|
||||||
let apiKeyBootstrapOtpIndex: number | null = null;
|
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const trimmed = key.trim();
|
const trimmed = key.trim();
|
||||||
@@ -1028,9 +1013,10 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
|
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed);
|
||||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||||
apiKeyBootstrapOtpIndex = publicIds.length;
|
credentials = initialized.credentials;
|
||||||
|
if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length;
|
||||||
}
|
}
|
||||||
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||||
return errorResponse('Invalid YubiKey OTP.', 400);
|
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||||
@@ -1071,6 +1057,7 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
|
|||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -1085,10 +1072,18 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
|
|||||||
|
|
||||||
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
|
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
|
||||||
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).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 replaceYubicoCredentials(env.DB, { clientId, secretKey });
|
||||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, 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));
|
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();
|
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
|
||||||
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
|
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||||
const credentials = await requestYubicoApiCredentials(user.email, otp);
|
const existing = await getYubicoCredentials(env.DB);
|
||||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
if (user.role !== 'admin' && existing) {
|
||||||
|
return errorResponse('Yubico validation credentials are already configured.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
let credentials;
|
||||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
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));
|
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -25,7 +25,8 @@ import {
|
|||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
|
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_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
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_WEBAUTHN = 7;
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
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
|
// 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
|
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||||
// compatible with older/local provider values.
|
// compatible with older/local provider values.
|
||||||
@@ -163,15 +162,6 @@ async function loginRateLimitKey(clientIdentifier: string, grantType: string, su
|
|||||||
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
|
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
|
||||||
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 {
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
const isHttps = new URL(request.url).protocol === 'https:';
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -507,8 +497,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
let credentials = await getYubicoCredentials(env.DB);
|
||||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
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);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
|||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { APP_VERSION } from '../../shared/app-version';
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||||
|
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||||
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
getAttachmentObjectKey,
|
getAttachmentObjectKey,
|
||||||
@@ -111,7 +112,7 @@ function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
|||||||
const sanitized: SqlRow[] = [];
|
const sanitized: SqlRow[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = String(row.key || '').trim();
|
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) {
|
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||||
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||||
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||||
|
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||||
import {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
@@ -276,7 +277,9 @@ async function prepareImportedConfigRows(
|
|||||||
configRows: SqlRow[],
|
configRows: SqlRow[],
|
||||||
userRows: SqlRow[]
|
userRows: SqlRow[]
|
||||||
): Promise<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 rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||||
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,7 @@ export interface Env {
|
|||||||
WEBAUTHN_RP_ID?: string;
|
WEBAUTHN_RP_ID?: string;
|
||||||
WEBAUTHN_RP_NAME?: string;
|
WEBAUTHN_RP_NAME?: string;
|
||||||
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||||
YUBICO_CLIENT_ID?: string;
|
|
||||||
YUBICO_SECRET_KEY?: string;
|
|
||||||
YUBICO_VALIDATION_URLS?: string;
|
YUBICO_VALIDATION_URLS?: string;
|
||||||
'globalSettings__yubico__clientId'?: string;
|
|
||||||
'globalSettings__yubico__key'?: string;
|
|
||||||
'globalSettings__yubico__validationUrls'?: string;
|
'globalSettings__yubico__validationUrls'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-22
@@ -48,12 +48,6 @@ export function isYubiKeyEnabled(user: User): boolean {
|
|||||||
return userYubiKeyPublicIds(user).length > 0;
|
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 {
|
function randomNonce(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
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(
|
export async function verifyYubicoOtp(
|
||||||
env: Env,
|
env: Env,
|
||||||
otpInput: string,
|
otpInput: string,
|
||||||
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
|
credentials: YubicoApiCredentials | null
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const otp = normalizeYubiKeyOtp(otpInput);
|
const otp = normalizeYubiKeyOtp(otpInput);
|
||||||
if (!isYubiKeyOtp(otp)) return false;
|
if (!isYubiKeyOtp(otp)) return false;
|
||||||
|
|
||||||
const clientId = String(credentials?.clientId || '').trim();
|
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 nonce = randomNonce();
|
||||||
const secretKey = String(credentials?.secretKey || '').trim();
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: clientId,
|
id: clientId,
|
||||||
nonce,
|
nonce,
|
||||||
otp,
|
otp,
|
||||||
});
|
});
|
||||||
if (secretKey) {
|
try {
|
||||||
try {
|
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
} catch {
|
||||||
} catch {
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const baseUrl of validationUrls(env)) {
|
for (const baseUrl of validationUrls(env)) {
|
||||||
@@ -172,14 +164,12 @@ export async function verifyYubicoOtp(
|
|||||||
if (!response.ok) continue;
|
if (!response.ok) continue;
|
||||||
const parsed = parseYubicoResponse(await response.text());
|
const parsed = parseYubicoResponse(await response.text());
|
||||||
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
||||||
if (secretKey) {
|
if (!parsed.h) continue;
|
||||||
if (!parsed.h) continue;
|
const signedParams = new URLSearchParams();
|
||||||
const signedParams = new URLSearchParams();
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
if (key !== 'h') signedParams.set(key, value);
|
||||||
if (key !== 'h') signedParams.set(key, value);
|
|
||||||
}
|
|
||||||
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
|
||||||
}
|
}
|
||||||
|
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
|
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
|
||||||
const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
|
const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
|
||||||
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
|
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
|
||||||
|
const [yubiKeyYubicoCanManage, setYubiKeyYubicoCanManage] = useState(false);
|
||||||
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
|
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
|
||||||
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
|
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
|
||||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||||
@@ -340,6 +341,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setYubiKeyStoredKeys(settings.keys);
|
setYubiKeyStoredKeys(settings.keys);
|
||||||
setYubiKeyNfc(settings.nfc);
|
setYubiKeyNfc(settings.nfc);
|
||||||
setYubiKeyYubicoConfigured(settings.yubicoConfigured);
|
setYubiKeyYubicoConfigured(settings.yubicoConfigured);
|
||||||
|
setYubiKeyYubicoCanManage(settings.yubicoCanManage);
|
||||||
setYubiKeyYubicoClientId(settings.yubicoClientId);
|
setYubiKeyYubicoClientId(settings.yubicoClientId);
|
||||||
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
|
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
|
||||||
}
|
}
|
||||||
@@ -352,6 +354,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||||
setYubiKeyNfc(false);
|
setYubiKeyNfc(false);
|
||||||
setYubiKeyYubicoConfigured(false);
|
setYubiKeyYubicoConfigured(false);
|
||||||
|
setYubiKeyYubicoCanManage(false);
|
||||||
setYubiKeyYubicoClientId('');
|
setYubiKeyYubicoClientId('');
|
||||||
setYubiKeyYubicoSecretKey('');
|
setYubiKeyYubicoSecretKey('');
|
||||||
setYubiKeyBootstrapOtp('');
|
setYubiKeyBootstrapOtp('');
|
||||||
@@ -1010,8 +1013,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{yubiKeyYubicoConfigured && (
|
{yubiKeyYubicoConfigured && yubiKeyYubicoCanManage && (
|
||||||
<>
|
|
||||||
<section className="settings-submodule yubikey-config-panel">
|
<section className="settings-submodule yubikey-config-panel">
|
||||||
<div className="settings-module-head">
|
<div className="settings-module-head">
|
||||||
<h3>{t('txt_yubikey_validation_credentials')}</h3>
|
<h3>{t('txt_yubikey_validation_credentials')}</h3>
|
||||||
@@ -1055,7 +1057,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yubiKeyYubicoConfigured && (
|
||||||
|
<>
|
||||||
<ol className="settings-plain-steps">
|
<ol className="settings-plain-steps">
|
||||||
<li>{t('txt_yubikey_plug_in')}</li>
|
<li>{t('txt_yubikey_plug_in')}</li>
|
||||||
<li>{t('txt_yubikey_select_empty_field')}</li>
|
<li>{t('txt_yubikey_select_empty_field')}</li>
|
||||||
|
|||||||
@@ -687,6 +687,7 @@ function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
|
|||||||
],
|
],
|
||||||
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
||||||
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
||||||
|
yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage),
|
||||||
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
||||||
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ export interface YubiKeyOtpSettings {
|
|||||||
keys: [string, string, string, string, string];
|
keys: [string, string, string, string, string];
|
||||||
nfc: boolean;
|
nfc: boolean;
|
||||||
yubicoConfigured: boolean;
|
yubicoConfigured: boolean;
|
||||||
|
yubicoCanManage: boolean;
|
||||||
yubicoClientId: string;
|
yubicoClientId: string;
|
||||||
yubicoSecretKey: string;
|
yubicoSecretKey: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user