mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
feat: Add YubiKey OTP support and management features
- Implemented YubiKey OTP settings management in useAccountSecurityActions hook. - Added API functions for retrieving, saving, and bootstrapping YubiKey OTP credentials. - Enhanced authentication flow to support multiple two-factor providers, including YubiKey. - Updated localization files to include new YubiKey-related strings in English, Spanish, Russian, and Chinese. - Introduced new styles for YubiKey management UI components. - Created utility functions for YubiKey OTP validation and credential handling.
This commit is contained in:
+246
-7
@@ -11,10 +11,14 @@ import { isTotpEnabled, verifyTotpToken } 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';
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
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
|
||||
@@ -193,6 +197,31 @@ function readNestedNumber(source: unknown, path: string[]): number | 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>> {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
@@ -322,6 +351,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
yubikeyKey1: null,
|
||||
yubikeyKey2: null,
|
||||
yubikeyKey3: null,
|
||||
yubikeyKey4: null,
|
||||
yubikeyKey5: null,
|
||||
yubikeyNfc: false,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -762,6 +797,29 @@ function twoFactorAuthenticatorResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function yubiKeyResponse(user: User): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: isYubiKeyEnabled(user),
|
||||
Key1: user.yubikeyKey1,
|
||||
Key2: user.yubikeyKey2,
|
||||
Key3: user.yubikeyKey3,
|
||||
Key4: user.yubikeyKey4,
|
||||
Key5: user.yubikeyKey5,
|
||||
Nfc: !!user.yubikeyNfc,
|
||||
Object: 'twoFactorYubiKey',
|
||||
};
|
||||
}
|
||||
|
||||
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
return {
|
||||
...yubiKeyResponse(user),
|
||||
YubicoConfigured: !!credentials?.clientId,
|
||||
YubicoClientId: credentials?.clientId ?? '',
|
||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/two-factor
|
||||
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
@@ -769,9 +827,9 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
const data = user.totpSecret
|
||||
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
|
||||
: [];
|
||||
const data = [];
|
||||
if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
|
||||
|
||||
return jsonResponse({
|
||||
Data: data,
|
||||
@@ -803,6 +861,27 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/get-yubikey
|
||||
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/authenticator
|
||||
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -849,6 +928,151 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey
|
||||
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const keys = [
|
||||
readBodyString(body, ['key1', 'Key1']),
|
||||
readBodyString(body, ['key2', 'Key2']),
|
||||
readBodyString(body, ['key3', 'Key3']),
|
||||
readBodyString(body, ['key4', 'Key4']),
|
||||
readBodyString(body, ['key5', 'Key5']),
|
||||
];
|
||||
const publicIds: Array<string | null> = [];
|
||||
let credentials = await getStoredYubicoCredentials(storage, env);
|
||||
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||
const existingPublicIds = [
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
].map((value) => String(value || '').trim().toLowerCase());
|
||||
for (const [index, key] of keys.entries()) {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
publicIds.push(null);
|
||||
continue;
|
||||
}
|
||||
const publicId = yubiKeyPublicIdFromOtp(trimmed);
|
||||
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
if (isYubiKeyPublicId(trimmed)) {
|
||||
if (existingPublicIds[index] !== publicId) {
|
||||
return errorResponse('A full YubiKey OTP is required to add or replace a key.', 400);
|
||||
}
|
||||
publicIds.push(publicId);
|
||||
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;
|
||||
}
|
||||
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
}
|
||||
publicIds.push(publicId);
|
||||
}
|
||||
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
|
||||
|
||||
user.yubikeyKey1 = publicIds[0] ?? null;
|
||||
user.yubikeyKey2 = publicIds[1] ?? null;
|
||||
user.yubikeyKey3 = publicIds[2] ?? null;
|
||||
user.yubikeyKey4 = publicIds[3] ?? null;
|
||||
user.yubikeyKey5 = publicIds[4] ?? null;
|
||||
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.yubikey.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey/config
|
||||
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
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);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/yubikey/bootstrap
|
||||
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
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);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
||||
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -865,7 +1089,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
|
||||
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
||||
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY].includes(type)) {
|
||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||
}
|
||||
|
||||
@@ -881,14 +1105,23 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
}
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
user.totpSecret = null;
|
||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
user.totpSecret = null;
|
||||
} else {
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.disable',
|
||||
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
@@ -896,7 +1129,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
|
||||
return jsonResponse(twoFactorProviderResponse(type, false));
|
||||
}
|
||||
|
||||
// PUT /api/accounts/totp
|
||||
@@ -1090,6 +1323,12 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
}
|
||||
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function handleAdminListUsers(
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5),
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env, TokenResponse } from '../types';
|
||||
import { Env, TokenResponse, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
@@ -23,12 +23,16 @@ 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';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
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.
|
||||
@@ -115,6 +119,15 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
|
||||
return 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 };
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -183,13 +196,19 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
function twoFactorRequiredResponse(user?: User, message: string = 'Two factor required.'): Response {
|
||||
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
||||
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||
const providers2: Record<string, { Email: null }> = {};
|
||||
for (const provider of providers) providers2[provider] = { Email: null };
|
||||
const providers: string[] = [];
|
||||
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
|
||||
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
|
||||
const providers2: Record<string, Record<string, unknown>> = {};
|
||||
for (const provider of providers) {
|
||||
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
||||
? { Nfc: user?.yubikeyNfc ?? false }
|
||||
: { Email: null };
|
||||
}
|
||||
const customResponse = {
|
||||
TwoFactorProviders: providers,
|
||||
TwoFactorProviders2: providers2,
|
||||
@@ -370,10 +389,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only by per-user secret.
|
||||
// Optional 2FA: enabled by any supported per-user provider.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||
if (effectiveTotpSecret) {
|
||||
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
||||
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0) {
|
||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
@@ -383,7 +403,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||
// respond with a 2FA challenge payload.
|
||||
if (!hasProvider || !hasToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return twoFactorRequiredResponse(user, 'Two factor required.');
|
||||
}
|
||||
|
||||
let passedByRememberToken = false;
|
||||
@@ -398,9 +418,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||
if (!passedByRememberToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return twoFactorRequiredResponse(user, 'Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
if (!effectiveTotpSecret) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (matchedCounter == null) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
@@ -409,6 +432,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
if (!consumed) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) {
|
||||
const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken);
|
||||
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (
|
||||
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
||||
@@ -418,6 +450,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
handleGetTwoFactorProviders,
|
||||
handleGetTwoFactorAuthenticator,
|
||||
handlePutTwoFactorAuthenticator,
|
||||
handleGetTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKeyConfig,
|
||||
handleBootstrapTwoFactorYubiKeyConfig,
|
||||
handleDisableTwoFactorProvider,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
@@ -141,12 +145,30 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
|
||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/authenticator') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
|
||||
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleDisableTwoFactorProvider(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
|
||||
@@ -297,6 +297,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
@@ -619,7 +620,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
|
||||
@@ -161,7 +161,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-07-03-yubikey-otp';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
|
||||
@@ -14,6 +14,12 @@ 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;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
@@ -49,6 +55,12 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
yubikeyKey1: string | null;
|
||||
yubikeyKey2: string | null;
|
||||
yubikeyKey3: string | null;
|
||||
yubikeyKey4: string | null;
|
||||
yubikeyKey5: string | null;
|
||||
yubikeyNfc: boolean;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -498,6 +510,7 @@ export interface ProfileResponse {
|
||||
masterPasswordHint: string | null;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
yubikeyEnabled?: boolean;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
accountKeys: any | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env, ProfileResponse, User } from '../types';
|
||||
import { buildAccountKeys } from './user-decryption';
|
||||
import { isYubiKeyEnabled } from './yubico-otp';
|
||||
|
||||
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
void env;
|
||||
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
|
||||
yubikeyEnabled: isYubiKeyEnabled(user),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
const YUBIKEY_PUBLIC_ID_LENGTH = 12;
|
||||
const YUBIKEY_MIN_OTP_LENGTH = 32;
|
||||
const YUBIKEY_MAX_OTP_LENGTH = 48;
|
||||
const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify';
|
||||
const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/';
|
||||
const MODHEX_RE = /^[cbdefghijklnrtuv]+$/;
|
||||
|
||||
export interface YubicoApiCredentials {
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export function normalizeYubiKeyOtp(input: string): string {
|
||||
return String(input || '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function yubiKeyPublicIdFromOtp(input: string): string | null {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp;
|
||||
if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null;
|
||||
if (!MODHEX_RE.test(otp)) return null;
|
||||
return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH);
|
||||
}
|
||||
|
||||
export function isYubiKeyPublicId(input: string): boolean {
|
||||
const value = normalizeYubiKeyOtp(input);
|
||||
return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value);
|
||||
}
|
||||
|
||||
function isYubiKeyOtp(input: string): boolean {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp);
|
||||
}
|
||||
|
||||
export function userYubiKeyPublicIds(user: User): string[] {
|
||||
return [
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
function parseYubicoResponse(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
out[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function base64ToBytes(input: string): Uint8Array {
|
||||
const binary = atob(input);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBase64(input: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of input) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function hmacSha1Base64(base64Key: string, message: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
base64ToBytes(base64Key),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
|
||||
}
|
||||
|
||||
function canonicalQuery(params: URLSearchParams): string {
|
||||
return Array.from(params.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function validationUrls(env: Env): string[] {
|
||||
const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL];
|
||||
}
|
||||
|
||||
export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise<YubicoApiCredentials | null> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return null;
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('email', String(email || '').trim().toLowerCase());
|
||||
body.set('otp', otp);
|
||||
body.set('terms_conditions', 'consented');
|
||||
|
||||
const response = await fetch(YUBICO_GET_API_KEY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const html = await response.text();
|
||||
const clientId = /Client ID:<\/th>\s*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
|
||||
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
|
||||
return clientId ? { clientId, secretKey } : null;
|
||||
}
|
||||
|
||||
export async function verifyYubicoOtp(
|
||||
env: Env,
|
||||
otpInput: string,
|
||||
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
|
||||
): Promise<boolean> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return false;
|
||||
|
||||
const clientId = String(credentials?.clientId || '').trim();
|
||||
if (!clientId) return false;
|
||||
|
||||
const nonce = randomNonce();
|
||||
const secretKey = String(credentials?.secretKey || '').trim();
|
||||
const params = new URLSearchParams({
|
||||
id: clientId,
|
||||
nonce,
|
||||
otp,
|
||||
});
|
||||
if (secretKey) {
|
||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||
}
|
||||
|
||||
for (const baseUrl of validationUrls(env)) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' });
|
||||
if (!response.ok) continue;
|
||||
const parsed = parseYubicoResponse(await response.text());
|
||||
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
||||
if (secretKey && parsed.h) {
|
||||
const signedParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key !== 'h') signedParams.set(key, value);
|
||||
}
|
||||
if ((await hmacSha1Base64(secretKey, canonicalQuery(signedParams))) !== parsed.h) continue;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user