mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
feat: add passkey-based two-factor authentication
This commit is contained in:
@@ -241,6 +241,7 @@ CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'login',
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url } from '../utils/passkey';
|
||||
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
verifyAccountPasskeyToken,
|
||||
} from '../utils/account-passkeys';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import { createRecoveryCode } from '../utils/recovery-code';
|
||||
|
||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||
|
||||
function parseBodyObject(body: unknown): Record<string, any> {
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||
}
|
||||
|
||||
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: credentials.length > 0,
|
||||
enabled: credentials.length > 0,
|
||||
Keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
Object: 'twoFactorWebAuthn',
|
||||
object: 'twoFactorWebAuthn',
|
||||
};
|
||||
}
|
||||
|
||||
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readPrfKeySet(body: Record<string, any>): {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
|
||||
if (payload.userId && credential.userId !== payload.userId) {
|
||||
throw new Error('Passkey does not belong to this user');
|
||||
}
|
||||
if (credential.purpose !== 'login') {
|
||||
throw new Error('Passkey is not registered for login');
|
||||
}
|
||||
|
||||
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (!credentials.length) return null;
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||
return options as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User,
|
||||
deviceResponse: unknown
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||
throw new Error('Passkey is not registered for two-step login');
|
||||
}
|
||||
|
||||
const challenge = readAuthenticationChallenge(response);
|
||||
if (!challenge) {
|
||||
throw new Error('Passkey assertion challenge is missing');
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorAuthentication',
|
||||
user.id,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: false,
|
||||
});
|
||||
if (!verification.verified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'discouraged',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'discouraged',
|
||||
},
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||
return jsonResponse(options);
|
||||
}
|
||||
|
||||
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
const challenge = readRegistrationChallenge(registrationResponse);
|
||||
if (!challenge) {
|
||||
return errorResponse('Passkey challenge is missing', 400);
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorCreate',
|
||||
userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
await storage.saveAccountPasskeyCredential({
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'twoFactor',
|
||||
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
supportsPrf: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.updatedAt = now;
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const requestedId = Number(body.id ?? body.Id);
|
||||
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||
return errorResponse('Invalid key id', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length < 2) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
const credential = credentials[requestedId - 1];
|
||||
if (!credential) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.delete',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'login',
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verif
|
||||
|
||||
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';
|
||||
@@ -830,6 +831,8 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
||||
const data = [];
|
||||
if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
|
||||
|
||||
return jsonResponse({
|
||||
Data: data,
|
||||
@@ -1089,7 +1092,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 (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY].includes(type)) {
|
||||
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
|
||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||
}
|
||||
|
||||
@@ -1107,13 +1110,18 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
|
||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
user.totpSecret = null;
|
||||
} else {
|
||||
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
} else {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of credentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
@@ -1121,7 +1129,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable',
|
||||
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
? 'account.totp.disable'
|
||||
: type === TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
? 'account.yubikey.disable'
|
||||
: 'account.webauthn_2fa.disable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
@@ -1329,6 +1341,10 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of webAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import {
|
||||
assertAccountPasskeyCredential,
|
||||
assertTwoFactorPasskeyCredential,
|
||||
buildAccountPasskeyTokenUserDecryptionOption,
|
||||
buildTwoFactorPasskeyAssertionOptions,
|
||||
} from './account-passkeys';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
@@ -29,6 +31,7 @@ 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_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';
|
||||
@@ -196,17 +199,30 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(user?: User, message: string = 'Two factor required.'): Response {
|
||||
async function twoFactorRequiredResponse(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user?: User,
|
||||
message: string = 'Two factor required.'
|
||||
): Promise<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[] = [];
|
||||
let webAuthnOptions: Record<string, unknown> | null = null;
|
||||
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
|
||||
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
|
||||
if (user) {
|
||||
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
|
||||
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
|
||||
}
|
||||
const providers2: Record<string, Record<string, unknown>> = {};
|
||||
for (const provider of providers) {
|
||||
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
||||
? { Nfc: user?.yubikeyNfc ?? false }
|
||||
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
|
||||
? webAuthnOptions
|
||||
: { Email: null };
|
||||
}
|
||||
const customResponse = {
|
||||
@@ -393,7 +409,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
||||
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0) {
|
||||
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.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());
|
||||
@@ -403,7 +420,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(user, 'Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
|
||||
let passedByRememberToken = false;
|
||||
@@ -418,7 +435,7 @@ 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(user, 'Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
if (!effectiveTotpSecret) {
|
||||
@@ -441,6 +458,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
||||
if (!effectiveWebAuthnCredentials.length) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
let deviceResponse: unknown;
|
||||
try {
|
||||
deviceResponse = JSON.parse(normalizedTwoFactorToken);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
try {
|
||||
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (
|
||||
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
||||
@@ -456,6 +488,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
for (const credential of effectiveWebAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
@@ -78,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||
import {
|
||||
handleCreateAccountPasskeyCredential,
|
||||
handleDeleteAccountPasskeyCredential,
|
||||
handleDeleteTwoFactorWebAuthn,
|
||||
handleGetAccountPasskeyAttestationOptions,
|
||||
handleGetAccountPasskeyCredentials,
|
||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||
handleGetTwoFactorWebAuthn,
|
||||
handleGetTwoFactorWebAuthnChallenge,
|
||||
handlePutTwoFactorWebAuthn,
|
||||
handleUpdateAccountPasskeyEncryption,
|
||||
} from './handlers/account-passkeys';
|
||||
import {
|
||||
@@ -149,6 +153,14 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/authenticator') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
@@ -161,6 +173,12 @@ export async function handleAuthenticatedRoute(
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/webauthn') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
|
||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||
{ name: 'id', sql: 'id TEXT' },
|
||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
|
||||
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
purpose?: string | null;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||
name: row.name,
|
||||
publicKey: row.public_key,
|
||||
credentialId: row.credential_id,
|
||||
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||
),
|
||||
credential.id,
|
||||
credential.userId,
|
||||
credential.purpose,
|
||||
credential.name,
|
||||
credential.publicKey,
|
||||
credential.credentialId,
|
||||
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
||||
.bind(userId)
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||
.bind(userId, purpose)
|
||||
.all<any>();
|
||||
return (rows.results || []).map(mapCredentialRow);
|
||||
}
|
||||
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||
.bind(userId, purpose)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
||||
.bind(userId, id)
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||
.bind(userId, id, purpose)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -140,10 +140,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||
|
||||
+17
-7
@@ -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-07-03-yubikey-otp';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
@@ -398,8 +398,11 @@ export class StorageService {
|
||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async getAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||
@@ -410,8 +413,11 @@ export class StorageService {
|
||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||
}
|
||||
|
||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async countAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyCounter(
|
||||
@@ -442,8 +448,12 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
||||
async deleteAccountPasskeyCredential(
|
||||
userId: string,
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||
}
|
||||
|
||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||
|
||||
+7
-1
@@ -252,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
purpose: 'login' | 'twoFactor';
|
||||
name: string;
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
@@ -267,7 +268,12 @@ export interface AccountPasskeyCredential {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
||||
export type AccountPasskeyChallengeScope =
|
||||
| 'Authentication'
|
||||
| 'CreateCredential'
|
||||
| 'UpdateKeySet'
|
||||
| 'TwoFactorAuthentication'
|
||||
| 'TwoFactorCreate';
|
||||
|
||||
export interface AccountPasskeyChallenge {
|
||||
challengeHash: string;
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
|
||||
}
|
||||
|
||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
|
||||
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
|
||||
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyToken(
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--line: #d8e0ec;
|
||||
--panel: #ffffff;
|
||||
--surface: #f6f8fb;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.connector-card {
|
||||
width: min(100%, 430px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remember input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-strong);
|
||||
border-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="connector-card" aria-labelledby="title">
|
||||
<div class="brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
|
||||
<strong>NodeWarden</strong>
|
||||
</div>
|
||||
<h1 id="title">Verify your identity</h1>
|
||||
<p id="subtitle">Use your security key to finish two-step verification.</p>
|
||||
<div class="form">
|
||||
<div id="msg" class="msg" role="status" aria-live="polite"></div>
|
||||
<label class="remember">
|
||||
<input id="remember" type="checkbox" />
|
||||
<span id="remember-label">Trust this device for 30 days</span>
|
||||
</label>
|
||||
<button id="webauthn-button" type="button">Read security key</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sentSuccess = false;
|
||||
|
||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||
|
||||
var titleEl = document.getElementById("title");
|
||||
var subtitleEl = document.getElementById("subtitle");
|
||||
var rememberEl = document.getElementById("remember");
|
||||
var rememberLabelEl = document.getElementById("remember-label");
|
||||
var buttonEl = document.getElementById("webauthn-button");
|
||||
var msgEl = document.getElementById("msg");
|
||||
|
||||
titleEl.textContent = text.title;
|
||||
subtitleEl.textContent = text.subtitle;
|
||||
rememberLabelEl.textContent = text.remember;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
|
||||
buttonEl.addEventListener("click", start);
|
||||
|
||||
function pickText(locale) {
|
||||
var normalized = String(locale || "en").toLowerCase();
|
||||
if (normalized.indexOf("zh") === 0) {
|
||||
return {
|
||||
title: "\u9a8c\u8bc1\u8eab\u4efd",
|
||||
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
|
||||
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
|
||||
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
|
||||
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
|
||||
success: "\u9a8c\u8bc1\u5b8c\u6210",
|
||||
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Verify your identity",
|
||||
subtitle: "Use your security key to finish two-step verification.",
|
||||
remember: "Trust this device for 30 days",
|
||||
button: "Read security key",
|
||||
awaiting: "Awaiting security key interaction...",
|
||||
success: "Verification complete",
|
||||
unsupported: "This browser does not support security keys",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRepeated(value) {
|
||||
if (!value) return "";
|
||||
var out = String(value);
|
||||
for (var i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
var next = decodeURIComponent(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function showMessage(kind, message) {
|
||||
msgEl.textContent = String(message || "");
|
||||
msgEl.className = "msg show " + kind;
|
||||
}
|
||||
|
||||
function decodeBase64Unicode(value) {
|
||||
var input = String(value || "").replace(/ /g, "+");
|
||||
try {
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
} catch (_error) {
|
||||
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFromBase64Url(value) {
|
||||
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
var binary = atob(normalized);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlFromBuffer(value) {
|
||||
if (!value) return undefined;
|
||||
var bytes = value instanceof Uint8Array
|
||||
? value
|
||||
: new Uint8Array(value);
|
||||
var binary = "";
|
||||
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function readPublicKeyOptions() {
|
||||
var data = params.get("data");
|
||||
if (!data) throw new Error("No data.");
|
||||
var decoded = decodeBase64Unicode(data);
|
||||
if (params.get("v") === "1") {
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
var payload = JSON.parse(decoded);
|
||||
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
|
||||
var copy = Object.assign({}, options);
|
||||
copy.challenge = bytesFromBase64Url(copy.challenge);
|
||||
if (Array.isArray(copy.allowCredentials)) {
|
||||
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
||||
return Object.assign({}, credential, {
|
||||
id: bytesFromBase64Url(credential.id),
|
||||
});
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function credentialToDataString(credential) {
|
||||
var response = credential.response;
|
||||
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
|
||||
var data = {
|
||||
id: credential.id,
|
||||
rawId: base64UrlFromBuffer(credential.rawId),
|
||||
type: credential.type,
|
||||
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
response: {
|
||||
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
||||
clientDataJson: clientDataJSON,
|
||||
clientDataJSON: clientDataJSON,
|
||||
signature: base64UrlFromBuffer(response.signature),
|
||||
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (sentSuccess) return;
|
||||
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
|
||||
showMessage("error", text.unsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
msgEl.className = "msg";
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
|
||||
var publicKey = normalizeOptions(readPublicKeyOptions());
|
||||
var credential = await navigator.credentials.get({ publicKey: publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error("No security key was selected.");
|
||||
}
|
||||
window.postMessage({
|
||||
command: "webAuthnResult",
|
||||
data: credentialToDataString(credential),
|
||||
remember: rememberEl.checked,
|
||||
}, "*");
|
||||
sentSuccess = true;
|
||||
showMessage("success", text.success);
|
||||
} catch (error) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+37
-4
@@ -58,6 +58,7 @@ import {
|
||||
type PendingPasskeyPassword,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
import useAdminActions from '@/hooks/useAdminActions';
|
||||
import useBackupActions from '@/hooks/useBackupActions';
|
||||
@@ -152,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
|
||||
type ThemePreference = 'system' | 'light' | 'dark';
|
||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||
@@ -654,19 +657,38 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectTotpProvider(providerType: number) {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp((current) => {
|
||||
if (!current || current.providerType === providerType) return current;
|
||||
const canUseProvider = current.availableProviders.includes(providerType);
|
||||
if (!canUseProvider) return current;
|
||||
return {
|
||||
...current,
|
||||
providerType,
|
||||
providerData: current.providerDataByType[providerType],
|
||||
};
|
||||
});
|
||||
setTotpCode('');
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
if (!isPasskeyTwoFactor && !totpCode.trim()) {
|
||||
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
const token = isPasskeyTwoFactor
|
||||
? await assertTwoFactorPasskey(pendingTotp.providerData)
|
||||
: totpCode;
|
||||
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : t('txt_totp_verify_failed'));
|
||||
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
@@ -952,11 +974,13 @@ export default function App() {
|
||||
onCancelConfirm={() => {}}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
@@ -1957,6 +1981,7 @@ export default function App() {
|
||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
|
||||
lockTimeoutMinutes,
|
||||
sessionTimeoutAction,
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
@@ -2014,6 +2039,10 @@ export default function App() {
|
||||
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
|
||||
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
|
||||
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
|
||||
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
@@ -2219,11 +2248,13 @@ export default function App() {
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
onRememberDeviceChange={setRememberDevice}
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onSelectTotpProvider={handleSelectTotpProvider}
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
@@ -2279,11 +2310,13 @@ export default function App() {
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -22,11 +23,13 @@ interface AppGlobalOverlaysProps {
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
pendingTotpAvailableProviders?: number[];
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onSelectTotpProvider: (providerType: number) => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
@@ -38,8 +41,40 @@ interface AppGlobalOverlaysProps {
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_ORDER = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
|
||||
const available = new Set(providerTypes || []);
|
||||
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
|
||||
}
|
||||
|
||||
function twoFactorProviderLabel(providerType: number): string {
|
||||
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
|
||||
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
|
||||
return t('txt_authenticator_app');
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === 3;
|
||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||
const availableProviders = useMemo(
|
||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||
[props.pendingTotpAvailableProviders]
|
||||
);
|
||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -57,10 +92,11 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : t('txt_password_is_already_verified')}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? `${t('txt_two_step_verification')} ${t('txt_passkey')}` : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
@@ -69,16 +105,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
{alternateProviders.length > 0 && (
|
||||
<div className="two-factor-method-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.totpSubmitting}
|
||||
aria-expanded={methodChooserOpen}
|
||||
onClick={() => setMethodChooserOpen((open) => !open)}
|
||||
>
|
||||
{t('txt_select_another_verification_method')}
|
||||
</button>
|
||||
{methodChooserOpen && (
|
||||
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
|
||||
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
|
||||
{alternateProviders.map((providerType) => (
|
||||
<button
|
||||
key={providerType}
|
||||
type="button"
|
||||
className="btn btn-secondary two-factor-method-option"
|
||||
disabled={props.totpSubmitting}
|
||||
onClick={() => {
|
||||
setMethodChooserOpen(false);
|
||||
props.onSelectTotpProvider(providerType);
|
||||
}}
|
||||
>
|
||||
{twoFactorProviderLabel(providerType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
@@ -90,7 +162,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -56,6 +56,7 @@ export interface AppMainRoutesProps {
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
@@ -118,6 +119,10 @@ export interface AppMainRoutesProps {
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -276,6 +281,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
passkey2faEnabled={props.passkey2faEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
@@ -290,6 +296,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
|
||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
||||
}
|
||||
|
||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||
const starBoxes = [1, 2, 3, 4];
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
return (
|
||||
<main className="not-found-page">
|
||||
<div className="not-found-space" aria-hidden="true">
|
||||
{starBoxes.map((box) => (
|
||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
||||
{stars.map((star) => (
|
||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||
<div className="not-found-brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||
</div>
|
||||
|
||||
<div className="not-found-astro-stage" aria-hidden="true">
|
||||
<div className="not-found-astronaut">
|
||||
<div className="not-found-astro-head" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
||||
<div className="not-found-astro-body">
|
||||
<div className="not-found-astro-panel" />
|
||||
</div>
|
||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
||||
<div className="not-found-astro-pack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="not-found-copy">
|
||||
<div className="not-found-code">404</div>
|
||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { AccountPasskeyCredential, Profile, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, Profile, TwoFactorPasskeyCredential, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
themePreference: ThemePreference;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
@@ -24,6 +25,10 @@ interface SettingsPageProps {
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -47,6 +52,7 @@ type MasterPasswordPromptAction =
|
||||
| 'rotateApiKey'
|
||||
| 'manageTotp'
|
||||
| 'manageYubiKey'
|
||||
| 'managePasskey2fa'
|
||||
| 'createPasskey'
|
||||
| 'enablePasskeyDirectUnlock'
|
||||
| 'deletePasskey';
|
||||
@@ -143,6 +149,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
|
||||
const [yubiKeySubmitting, setYubiKeySubmitting] = useState(false);
|
||||
const [twoFactorPasskeyEnabled, setTwoFactorPasskeyEnabled] = useState(props.passkey2faEnabled);
|
||||
const [twoFactorPasskeys, setTwoFactorPasskeys] = useState<TwoFactorPasskeyCredential[]>([]);
|
||||
const [twoFactorPasskeyDialogOpen, setTwoFactorPasskeyDialogOpen] = useState(false);
|
||||
const [twoFactorPasskeyMasterPassword, setTwoFactorPasskeyMasterPassword] = useState('');
|
||||
const [twoFactorPasskeyName, setTwoFactorPasskeyName] = useState(t('txt_passkey'));
|
||||
const [twoFactorPasskeySubmitting, setTwoFactorPasskeySubmitting] = useState(false);
|
||||
const [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
|
||||
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
||||
const [totpManagePassword, setTotpManagePassword] = useState('');
|
||||
@@ -172,6 +184,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
||||
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setTwoFactorPasskeyEnabled(props.passkey2faEnabled);
|
||||
}, [props.passkey2faEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccountPasskeys();
|
||||
}, [props.profile.id]);
|
||||
@@ -250,6 +266,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
applyYubiKeySettings(settings);
|
||||
setYubiKeyConfigOpen(false);
|
||||
setYubiKeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'managePasskey2fa') {
|
||||
const settings = await props.onGetTwoFactorPasskeySettings(masterPassword);
|
||||
setTwoFactorPasskeyMasterPassword(masterPassword);
|
||||
applyTwoFactorPasskeySettings(settings);
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
setTwoFactorPasskeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setCreatePasskeyMasterPassword(masterPassword);
|
||||
@@ -284,6 +306,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
? t('txt_totp')
|
||||
: masterPasswordPrompt === 'manageYubiKey'
|
||||
? 'YubiKey'
|
||||
: masterPasswordPrompt === 'managePasskey2fa'
|
||||
? t('txt_two_step_passkeys')
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
@@ -327,7 +351,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyNfc(false);
|
||||
setYubiKeyEnabled(false);
|
||||
setYubiKeyYubicoConfigured(false);
|
||||
setYubiKeyYubicoClientId('');
|
||||
setYubiKeyYubicoSecretKey('');
|
||||
@@ -402,6 +425,60 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyTwoFactorPasskeySettings(settings: TwoFactorPasskeySettings): void {
|
||||
setTwoFactorPasskeyEnabled(settings.enabled);
|
||||
setTwoFactorPasskeys(settings.keys);
|
||||
}
|
||||
|
||||
function closeTwoFactorPasskeyDialog(): void {
|
||||
if (twoFactorPasskeySubmitting) return;
|
||||
setTwoFactorPasskeyDialogOpen(false);
|
||||
setTwoFactorPasskeyMasterPassword('');
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
}
|
||||
|
||||
async function createTwoFactorPasskeyDialog(): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
const settings = await props.onCreateTwoFactorPasskey(twoFactorPasskeyName, twoFactorPasskeyMasterPassword);
|
||||
applyTwoFactorPasskeySettings(settings);
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_passkey_setup_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTwoFactorPasskeyDialog(id: number): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || twoFactorPasskeys.length < 2) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
applyTwoFactorPasskeySettings(await props.onDeleteTwoFactorPasskey(id, twoFactorPasskeyMasterPassword));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTwoFactorPasskeysDialog(): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || !twoFactorPasskeyEnabled) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
await props.onDisableTwoFactorPasskeys(twoFactorPasskeyMasterPassword);
|
||||
applyTwoFactorPasskeySettings({ enabled: false, keys: [] });
|
||||
setTwoFactorPasskeyDialogOpen(false);
|
||||
setTwoFactorPasskeyMasterPassword('');
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_disable_passkey_two_step_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTwoFactorStatus(): Promise<void> {
|
||||
if (twoFactorStatusRefreshing) return;
|
||||
setTwoFactorStatusRefreshing(true);
|
||||
@@ -726,10 +803,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<KeyRound size={28} />
|
||||
</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<div className="two-step-provider-title">
|
||||
<strong>{t('txt_passkeys')}</strong>
|
||||
{twoFactorPasskeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
|
||||
</div>
|
||||
<span>{t('txt_passkey_provider_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('managePasskey2fa')}>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1028,13 +1108,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={twoFactorPasskeyDialogOpen}
|
||||
title={t('txt_two_step_passkeys')}
|
||||
message={t('txt_two_step_passkeys_help')}
|
||||
hideConfirm
|
||||
hideCancel
|
||||
closeButton
|
||||
cancelDisabled={twoFactorPasskeySubmitting}
|
||||
onConfirm={() => {}}
|
||||
onCancel={closeTwoFactorPasskeyDialog}
|
||||
>
|
||||
<div className="settings-vertical-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="two-factor-passkey-name">{t('txt_passkey_name')}</label>
|
||||
<div className="two-factor-passkey-register-row">
|
||||
<input
|
||||
id="two-factor-passkey-name"
|
||||
className="input"
|
||||
maxLength={128}
|
||||
value={twoFactorPasskeyName}
|
||||
placeholder={t('txt_two_step_passkey_name_placeholder')}
|
||||
onInput={(e) => setTwoFactorPasskeyName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={twoFactorPasskeySubmitting}
|
||||
onClick={() => void createTwoFactorPasskeyDialog()}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_register')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="two-factor-passkey-list-block">
|
||||
<div className="settings-list-label">{t('txt_key_list')}</div>
|
||||
{twoFactorPasskeys.length > 0 ? (
|
||||
<div className="account-passkey-list">
|
||||
{twoFactorPasskeys.map((credential, index) => (
|
||||
<div key={credential.id} className="account-passkey-row two-factor-passkey-row">
|
||||
<span className="account-passkey-index">{index + 1}</span>
|
||||
<div className="account-passkey-main">
|
||||
<strong>{credential.name || t('txt_dash')}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={twoFactorPasskeySubmitting || twoFactorPasskeys.length < 2}
|
||||
title={twoFactorPasskeys.length < 2 ? t('txt_remove_last_passkey_hint') : t('txt_delete')}
|
||||
onClick={() => void deleteTwoFactorPasskeyDialog(credential.id)}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-inline settings-field-note">{t('txt_no_two_step_passkeys')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions two-factor-passkey-danger-actions">
|
||||
{twoFactorPasskeyEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={twoFactorPasskeySubmitting}
|
||||
onClick={() => void disableTwoFactorPasskeysDialog()}
|
||||
>
|
||||
{t('txt_disable_all_keys')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={recoveryCodeDialogOpen}
|
||||
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
||||
message={t('txt_your_two_step_recovery_code')}
|
||||
hideConfirm
|
||||
hideCancel
|
||||
closeButton
|
||||
cancelText={t('txt_close')}
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => setRecoveryCodeDialogOpen(false)}
|
||||
afterActions={(
|
||||
|
||||
@@ -7,19 +7,24 @@ import {
|
||||
deleteAuthorizedDevices,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
|
||||
disableYubiKeyOtp,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getAccountPasskeyAttestationOptions,
|
||||
getAccountPasskeyUpdateAssertionOptions,
|
||||
getTotpRecoveryCode,
|
||||
getTwoFactorPasskeyChallenge,
|
||||
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
|
||||
getYubiKeyOtpSettings,
|
||||
listAccountPasskeys,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveAccountPasskey,
|
||||
saveTwoFactorPasskey,
|
||||
saveYubiKeyOtpApiCredentials,
|
||||
saveYubiKeyOtpSettings,
|
||||
setTotp,
|
||||
@@ -33,11 +38,12 @@ import {
|
||||
buildAccountPasskeyPrfKeySet,
|
||||
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||
createAccountPasskeyCredential,
|
||||
createTwoFactorPasskeyCredential,
|
||||
} from '@/lib/account-passkeys';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
@@ -257,6 +263,53 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onNotify('success', t('txt_yubikey_disabled'));
|
||||
},
|
||||
|
||||
async getTwoFactorPasskeySettings(masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
return getTwoFactorPasskeySettingsApi(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async createTwoFactorPasskey(name: string, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const normalizedName = String(name || '').trim() || t('txt_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const challenge = await getTwoFactorPasskeyChallenge(authedFetch, derived.hash);
|
||||
const deviceResponse = await createTwoFactorPasskeyCredential(challenge);
|
||||
const settings = await saveTwoFactorPasskey(authedFetch, {
|
||||
name: normalizedName,
|
||||
masterPasswordHash: derived.hash,
|
||||
deviceResponse,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_added'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async deleteTwoFactorPasskey(id: number, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await deleteTwoFactorPasskeyApi(authedFetch, { id, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_removed'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableTwoFactorPasskeys(masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
await disableTwoFactorPasskeysApi(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkeys_disabled'));
|
||||
},
|
||||
|
||||
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
|
||||
@@ -340,6 +340,28 @@ export async function createAccountPasskeyCredential(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
return attestationRequest(credential);
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_invalid_passkey_assertion_response'));
|
||||
}
|
||||
return JSON.stringify(assertionRequest(credential));
|
||||
}
|
||||
|
||||
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||
const text = String(value || '').trim();
|
||||
const [type, payload] = text.split('.');
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
TwoFactorPasskeySettings,
|
||||
YubiKeyOtpSettings,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
@@ -756,6 +757,99 @@ export async function disableYubiKeyOtp(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
|
||||
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: keys
|
||||
.map((item: any) => ({
|
||||
id: Number(item?.id ?? item?.Id),
|
||||
name: String(item?.name || item?.Name || ''),
|
||||
migrated: !!(item?.migrated ?? item?.Migrated),
|
||||
}))
|
||||
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeySettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeyChallenge(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<unknown> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return parseJson<unknown>(resp);
|
||||
}
|
||||
|
||||
export async function saveTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function deleteTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id: number; masterPasswordHash: string }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableTwoFactorPasskeys(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/two-factor/disable', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 7, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
@@ -913,7 +1007,7 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
|
||||
return stamp;
|
||||
}
|
||||
|
||||
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean }> {
|
||||
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
|
||||
const resp = await authedFetch('/api/two-factor');
|
||||
if (!resp.ok) throw new Error('Failed to load two-factor status');
|
||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||
@@ -926,6 +1020,7 @@ export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Prom
|
||||
return {
|
||||
totpEnabled: enabledTypes.has(0),
|
||||
yubikeyEnabled: enabledTypes.has(3),
|
||||
passkeyEnabled: enabledTypes.has(7),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+107
-20
@@ -35,6 +35,9 @@ export interface PendingTotp {
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
providerType: number;
|
||||
providerData?: unknown;
|
||||
availableProviders: number[];
|
||||
providerDataByType: Record<number, unknown>;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
@@ -73,27 +76,94 @@ export interface CompletedLogin {
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
|
||||
}
|
||||
|
||||
function resolvePendingTwoFactorProvider(providers: unknown): number {
|
||||
type TwoFactorTokenError = {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
CustomResponse?: {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
};
|
||||
error_description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
|
||||
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
}
|
||||
|
||||
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return undefined;
|
||||
const record = providers2 as Record<string, unknown>;
|
||||
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
|
||||
}
|
||||
|
||||
function twoFactorProviderTypeFromValue(value: unknown): number | null {
|
||||
const raw = value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
|
||||
: value;
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return null;
|
||||
const normalized = text.toLowerCase();
|
||||
const numeric = Number(text);
|
||||
const provider = Number.isFinite(numeric)
|
||||
? numeric
|
||||
: normalized === 'webauthn'
|
||||
? TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
: normalized === 'yubikey' || normalized === 'yubikeyotp'
|
||||
? TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
: normalized === 'authenticator' || normalized === 'totp'
|
||||
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
: Number.NaN;
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
|
||||
}
|
||||
|
||||
function sortTwoFactorProviders(providerTypes: number[]): number[] {
|
||||
const unique = new Set(providerTypes);
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
|
||||
}
|
||||
|
||||
function readTwoFactorProviderTypes(providers: unknown): number[] {
|
||||
const providerTypes: number[] = [];
|
||||
if (Array.isArray(providers)) {
|
||||
if (providers.some((provider: any) => Number(
|
||||
provider && typeof provider === 'object' ? provider.Type ?? provider.type : provider
|
||||
) === TWO_FACTOR_PROVIDER_YUBIKEY)) {
|
||||
return TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
for (const provider of providers) {
|
||||
const providerType = twoFactorProviderTypeFromValue(provider);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
}
|
||||
if (providers && typeof providers === 'object') {
|
||||
const record = providers as Record<string, unknown>;
|
||||
if (record[String(TWO_FACTOR_PROVIDER_YUBIKEY)] || record.YubiKey || record.Yubikey) {
|
||||
return TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
} else if (providers && typeof providers === 'object') {
|
||||
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
|
||||
if (!value) continue;
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
}
|
||||
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
return sortTwoFactorProviders(providerTypes);
|
||||
}
|
||||
|
||||
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return {};
|
||||
const out: Record<number, unknown> = {};
|
||||
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) out[providerType] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePendingTwoFactorProvider(providers: unknown): number {
|
||||
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
}
|
||||
|
||||
export type PasswordLoginResult =
|
||||
@@ -438,8 +508,12 @@ export async function performPasswordLogin(
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -447,7 +521,10 @@ export async function performPasswordLogin(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -528,7 +605,10 @@ export async function performTotpLogin(
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
? t('txt_passkey_verification_failed')
|
||||
: t('txt_totp_verify_failed');
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
|
||||
}
|
||||
|
||||
export async function performRecoverTwoFactorLogin(
|
||||
@@ -608,7 +688,7 @@ export async function performUnlock(
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
let token: TokenSuccess | TwoFactorTokenError;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
@@ -630,8 +710,12 @@ export async function performUnlock(
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -639,7 +723,10 @@ export async function performUnlock(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -907,6 +907,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
adminLoading: false,
|
||||
adminError: '',
|
||||
totpEnabled: true,
|
||||
passkey2faEnabled: false,
|
||||
authorizedDevices: state.authorizedDevices,
|
||||
authorizedDevicesLoading: false,
|
||||
authorizedDevicesError: '',
|
||||
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onSavePasswordHint: readonly,
|
||||
onEnableTotp: readonly,
|
||||
onOpenDisableTotp: readonlyVoid,
|
||||
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
|
||||
onCreateTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDeleteTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDisableTwoFactorPasskeys: readonly,
|
||||
onGetRecoveryCode: readonlyString,
|
||||
onGetApiKey: readonlyString,
|
||||
onRotateApiKey: readonlyString,
|
||||
|
||||
@@ -806,6 +806,23 @@ const en: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
|
||||
"txt_passkey": "Passkey",
|
||||
"txt_passkeys": "Passkeys",
|
||||
"txt_register": "Register",
|
||||
"txt_key_list": "Key list",
|
||||
"txt_select_another_verification_method": "Select another verification method",
|
||||
"txt_select_two_step_login_method": "Select two-step login method",
|
||||
"txt_two_step_passkeys": "Passkey two-step login",
|
||||
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
|
||||
"txt_two_step_passkey_name_placeholder": "Security key",
|
||||
"txt_add_two_step_passkey": "Add passkey",
|
||||
"txt_two_step_passkey_added": "Passkey two-step login updated",
|
||||
"txt_two_step_passkey_removed": "Passkey removed",
|
||||
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
|
||||
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
|
||||
"txt_no_two_step_passkeys": "No two-step passkeys",
|
||||
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
|
||||
"txt_passkey_setup_failed": "Passkey setup failed",
|
||||
"txt_passkey_created_at_value": "Created on {value}",
|
||||
"txt_account_passkey": "Account passkey",
|
||||
"txt_account_passkeys": "Account passkeys",
|
||||
|
||||
@@ -806,6 +806,23 @@ const es: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
|
||||
"txt_passkey": "Clave de acceso",
|
||||
"txt_passkeys": "Claves de acceso",
|
||||
"txt_register": "Registrar",
|
||||
"txt_key_list": "Lista de claves",
|
||||
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
|
||||
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
|
||||
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
|
||||
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
|
||||
"txt_add_two_step_passkey": "Agregar clave de acceso",
|
||||
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
|
||||
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
|
||||
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
|
||||
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
|
||||
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
|
||||
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
|
||||
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
|
||||
"txt_passkey_created_at_value": "Creado el {value}",
|
||||
"txt_account_passkey": "Clave de acceso de cuenta",
|
||||
"txt_account_passkeys": "Claves de acceso de cuenta",
|
||||
|
||||
@@ -806,6 +806,23 @@ const ru: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
|
||||
"txt_passkey": "Ключ доступа",
|
||||
"txt_passkeys": "Ключи доступа",
|
||||
"txt_register": "Зарегистрировать",
|
||||
"txt_key_list": "Список ключей",
|
||||
"txt_select_another_verification_method": "Выбрать другой способ проверки",
|
||||
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
|
||||
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
|
||||
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
|
||||
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
|
||||
"txt_add_two_step_passkey": "Добавить ключ доступа",
|
||||
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
|
||||
"txt_two_step_passkey_removed": "Ключ доступа удален",
|
||||
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
|
||||
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
|
||||
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
|
||||
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
|
||||
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
|
||||
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
|
||||
"txt_passkey_created_at_value": "Создано {value}",
|
||||
"txt_account_passkey": "Ключ доступа аккаунта",
|
||||
"txt_account_passkeys": "Ключи доступа аккаунта",
|
||||
|
||||
@@ -806,6 +806,23 @@ const zhCN: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
|
||||
"txt_passkey": "通行密钥",
|
||||
"txt_passkeys": "通行密钥",
|
||||
"txt_register": "注册",
|
||||
"txt_key_list": "密钥列表",
|
||||
"txt_select_another_verification_method": "选择其他验证方式",
|
||||
"txt_select_two_step_login_method": "选择验证方式",
|
||||
"txt_two_step_passkeys": "通行密钥二步登录",
|
||||
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密钥",
|
||||
"txt_add_two_step_passkey": "添加通行密钥",
|
||||
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
|
||||
"txt_two_step_passkey_removed": "通行密钥已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
|
||||
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
|
||||
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
|
||||
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
|
||||
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
|
||||
"txt_passkey_setup_failed": "通行密钥设置失败",
|
||||
"txt_passkey_created_at_value": "创建于 {value}",
|
||||
"txt_account_passkey": "账号通行密钥",
|
||||
"txt_account_passkeys": "账号通行密钥",
|
||||
|
||||
@@ -806,6 +806,23 @@ const zhTW: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
|
||||
"txt_passkey": "通行密鑰",
|
||||
"txt_passkeys": "通行密鑰",
|
||||
"txt_register": "註冊",
|
||||
"txt_key_list": "密鑰列表",
|
||||
"txt_select_another_verification_method": "選擇其他驗證方式",
|
||||
"txt_select_two_step_login_method": "選擇驗證方式",
|
||||
"txt_two_step_passkeys": "通行密鑰兩步登入",
|
||||
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密鑰",
|
||||
"txt_add_two_step_passkey": "新增通行密鑰",
|
||||
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
|
||||
"txt_two_step_passkey_removed": "通行密鑰已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
|
||||
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
|
||||
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
|
||||
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
|
||||
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
|
||||
"txt_passkey_setup_failed": "通行密鑰設置失敗",
|
||||
"txt_passkey_created_at_value": "創建於 {value}",
|
||||
"txt_account_passkey": "賬號通行密鑰",
|
||||
"txt_account_passkeys": "賬號通行密鑰",
|
||||
|
||||
@@ -350,6 +350,17 @@ export interface AccountPasskeyCredential {
|
||||
revisionDate?: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeyCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
migrated?: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeySettings {
|
||||
enabled: boolean;
|
||||
keys: TwoFactorPasskeyCredential[];
|
||||
}
|
||||
|
||||
export interface AuthRequest {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
|
||||
@@ -30,14 +30,11 @@
|
||||
|
||||
.not-found-page {
|
||||
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
|
||||
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
|
||||
@apply relative z-20 grid w-full max-w-[560px] justify-items-center gap-6 px-4 py-7 text-center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
@@ -356,7 +353,7 @@
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||
@apply h-14 w-14 flex-shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -377,17 +374,16 @@
|
||||
|
||||
.not-found-copy {
|
||||
@apply grid justify-items-center gap-3;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.not-found-shell h1 {
|
||||
@apply m-0 text-3xl font-extrabold leading-tight;
|
||||
color: #f8fbff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.not-found-shell p {
|
||||
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
||||
color: rgba(220, 232, 251, 0.82);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.not-found-action {
|
||||
@@ -396,10 +392,7 @@
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.not-found-page {
|
||||
background:
|
||||
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
|
||||
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
|
||||
@@ -1352,7 +1352,7 @@
|
||||
}
|
||||
|
||||
.settings-module-head {
|
||||
@apply mb-[5px] flex items-center justify-between gap-3;
|
||||
@apply mb-[10px] flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.settings-module-head h3 {
|
||||
@@ -1382,10 +1382,37 @@
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.account-passkey-list,
|
||||
.account-passkeys-list {
|
||||
@apply mt-3 grid gap-2;
|
||||
}
|
||||
|
||||
.two-factor-passkey-register-row {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
|
||||
.two-factor-passkey-register-row .input {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.two-factor-passkey-register-row .btn {
|
||||
@apply shrink-0;
|
||||
min-width: 86px;
|
||||
}
|
||||
|
||||
.two-factor-passkey-list-block {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.settings-list-label {
|
||||
@apply text-sm font-extrabold;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.two-factor-passkey-danger-actions {
|
||||
@apply justify-end pt-1;
|
||||
}
|
||||
|
||||
.account-passkey-row {
|
||||
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
@@ -1393,6 +1420,17 @@
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.two-factor-passkey-row {
|
||||
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.account-passkey-index {
|
||||
@apply inline-grid h-8 w-8 place-items-center rounded-lg text-sm font-extrabold;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--panel) 80%, var(--panel-2));
|
||||
}
|
||||
|
||||
.account-passkey-main {
|
||||
@apply grid min-w-0 gap-1;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
.dialog-extra {
|
||||
@apply mt-2;
|
||||
@apply mt-2 grid gap-2;
|
||||
}
|
||||
|
||||
.dialog-divider {
|
||||
@@ -99,6 +99,25 @@
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.two-factor-method-switcher {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
.two-factor-method-list {
|
||||
@apply grid gap-2 rounded-lg border p-2;
|
||||
border-color: var(--line);
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--surface));
|
||||
}
|
||||
|
||||
.two-factor-method-label {
|
||||
@apply px-1 text-left text-sm font-extrabold;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.two-factor-method-option {
|
||||
@apply min-h-11 justify-start text-base;
|
||||
}
|
||||
|
||||
.import-summary-dialog {
|
||||
@apply relative max-w-[520px] pt-4 text-left;
|
||||
}
|
||||
|
||||
@@ -1143,6 +1143,15 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.two-factor-passkey-row {
|
||||
grid-template-columns: 2rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.two-factor-passkey-row .btn {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.account-passkey-status {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
@@ -1152,6 +1161,15 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.two-factor-passkey-register-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.two-factor-passkey-register-row .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-module .totp-grid,
|
||||
.settings-submodule .totp-grid {
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user