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 (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL DEFAULT 'login',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
credential_id TEXT NOT NULL,
|
credential_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { bytesToBase64Url } from '../utils/passkey';
|
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||||
import {
|
import {
|
||||||
accountPasskeyCredentialToResponse,
|
accountPasskeyCredentialToResponse,
|
||||||
accountPasskeyPrfStatus,
|
accountPasskeyPrfStatus,
|
||||||
@@ -29,8 +29,10 @@ import {
|
|||||||
verifyAccountPasskeyToken,
|
verifyAccountPasskeyToken,
|
||||||
} from '../utils/account-passkeys';
|
} from '../utils/account-passkeys';
|
||||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
|
import { createRecoveryCode } from '../utils/recovery-code';
|
||||||
|
|
||||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||||
|
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||||
|
|
||||||
function parseBodyObject(body: unknown): Record<string, any> {
|
function parseBodyObject(body: unknown): Record<string, any> {
|
||||||
return body && typeof body === 'object' ? body as 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);
|
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>): {
|
function readPrfKeySet(body: Record<string, any>): {
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
|
|||||||
if (payload.userId && credential.userId !== payload.userId) {
|
if (payload.userId && credential.userId !== payload.userId) {
|
||||||
throw new Error('Passkey does not belong to this user');
|
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 userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
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> {
|
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
const body = await readJsonBody(request);
|
const body = await readJsonBody(request);
|
||||||
if (!body) return errorResponse('Invalid request payload', 400);
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
|
|||||||
const credential: AccountPasskeyCredential = {
|
const credential: AccountPasskeyCredential = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
userId,
|
userId,
|
||||||
|
purpose: 'login',
|
||||||
name: normalizeAccountPasskeyName(body.name),
|
name: normalizeAccountPasskeyName(body.name),
|
||||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||||
credentialId: verification.registrationInfo.credential.id,
|
credentialId: verification.registrationInfo.credential.id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verif
|
|||||||
|
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||||
@@ -830,6 +831,8 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
|||||||
const data = [];
|
const data = [];
|
||||||
if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||||
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, 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({
|
return jsonResponse({
|
||||||
Data: data,
|
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 typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||||
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
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);
|
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) {
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
user.totpSecret = null;
|
user.totpSecret = null;
|
||||||
} else {
|
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
|
||||||
user.yubikeyKey1 = null;
|
user.yubikeyKey1 = null;
|
||||||
user.yubikeyKey2 = null;
|
user.yubikeyKey2 = null;
|
||||||
user.yubikeyKey3 = null;
|
user.yubikeyKey3 = null;
|
||||||
user.yubikeyKey4 = null;
|
user.yubikeyKey4 = null;
|
||||||
user.yubikeyKey5 = null;
|
user.yubikeyKey5 = null;
|
||||||
user.yubikeyNfc = false;
|
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();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
@@ -1121,7 +1129,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
AuthService.invalidateUserCache(user.id);
|
AuthService.invalidateUserCache(user.id);
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
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',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'security',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
@@ -1329,6 +1341,10 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
user.yubikeyKey4 = null;
|
user.yubikeyKey4 = null;
|
||||||
user.yubikeyKey5 = null;
|
user.yubikeyKey5 = null;
|
||||||
user.yubikeyNfc = false;
|
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.totpRecoveryCode = createRecoveryCode();
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
import {
|
import {
|
||||||
assertAccountPasskeyCredential,
|
assertAccountPasskeyCredential,
|
||||||
|
assertTwoFactorPasskeyCredential,
|
||||||
buildAccountPasskeyTokenUserDecryptionOption,
|
buildAccountPasskeyTokenUserDecryptionOption,
|
||||||
|
buildTwoFactorPasskeyAssertionOptions,
|
||||||
} from './account-passkeys';
|
} from './account-passkeys';
|
||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
@@ -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_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
const YUBICO_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.
|
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
// 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.
|
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||||
const providers: string[] = [];
|
const providers: string[] = [];
|
||||||
|
let webAuthnOptions: Record<string, unknown> | null = null;
|
||||||
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
|
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 && 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>> = {};
|
const providers2: Record<string, Record<string, unknown>> = {};
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
||||||
? { Nfc: user?.yubikeyNfc ?? false }
|
? { Nfc: user?.yubikeyNfc ?? false }
|
||||||
|
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
|
||||||
|
? webAuthnOptions
|
||||||
: { Email: null };
|
: { Email: null };
|
||||||
}
|
}
|
||||||
const customResponse = {
|
const customResponse = {
|
||||||
@@ -393,7 +409,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||||
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
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 normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').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,
|
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||||
// respond with a 2FA challenge payload.
|
// respond with a 2FA challenge payload.
|
||||||
if (!hasProvider || !hasToken) {
|
if (!hasProvider || !hasToken) {
|
||||||
return twoFactorRequiredResponse(user, 'Two factor required.');
|
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let passedByRememberToken = false;
|
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.
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
if (!passedByRememberToken) {
|
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)) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
if (!effectiveTotpSecret) {
|
if (!effectiveTotpSecret) {
|
||||||
@@ -441,6 +458,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
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 (
|
} else if (
|
||||||
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
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.yubikeyKey4 = null;
|
||||||
user.yubikeyKey5 = null;
|
user.yubikeyKey5 = null;
|
||||||
user.yubikeyNfc = false;
|
user.yubikeyNfc = false;
|
||||||
|
for (const credential of effectiveWebAuthnCredentials) {
|
||||||
|
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||||
|
}
|
||||||
user.totpRecoveryCode = createRecoveryCode();
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|||||||
@@ -78,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
|||||||
import {
|
import {
|
||||||
handleCreateAccountPasskeyCredential,
|
handleCreateAccountPasskeyCredential,
|
||||||
handleDeleteAccountPasskeyCredential,
|
handleDeleteAccountPasskeyCredential,
|
||||||
|
handleDeleteTwoFactorWebAuthn,
|
||||||
handleGetAccountPasskeyAttestationOptions,
|
handleGetAccountPasskeyAttestationOptions,
|
||||||
handleGetAccountPasskeyCredentials,
|
handleGetAccountPasskeyCredentials,
|
||||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||||
|
handleGetTwoFactorWebAuthn,
|
||||||
|
handleGetTwoFactorWebAuthnChallenge,
|
||||||
|
handlePutTwoFactorWebAuthn,
|
||||||
handleUpdateAccountPasskeyEncryption,
|
handleUpdateAccountPasskeyEncryption,
|
||||||
} from './handlers/account-passkeys';
|
} from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
@@ -149,6 +153,14 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
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 (path === '/api/two-factor/authenticator') {
|
||||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(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);
|
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')) {
|
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||||
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
|
|||||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||||
{ name: 'id', sql: 'id TEXT' },
|
{ name: 'id', sql: 'id TEXT' },
|
||||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
{ 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: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||||
{ name: 'credential_id', sql: "credential_id 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
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
'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, ' +
|
'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, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||||
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
|
|||||||
function mapCredentialRow(row: {
|
function mapCredentialRow(row: {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
purpose?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
credential_id: string;
|
credential_id: string;
|
||||||
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
|
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||||
name: row.name,
|
name: row.name,
|
||||||
publicKey: row.public_key,
|
publicKey: row.public_key,
|
||||||
credentialId: row.credential_id,
|
credentialId: row.credential_id,
|
||||||
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
|
|||||||
await safeBind(
|
await safeBind(
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT INTO webauthn_credentials(' +
|
'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' +
|
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'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, ' +
|
'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'
|
'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.id,
|
||||||
credential.userId,
|
credential.userId,
|
||||||
|
credential.purpose,
|
||||||
credential.name,
|
credential.name,
|
||||||
credential.publicKey,
|
credential.publicKey,
|
||||||
credential.credentialId,
|
credential.credentialId,
|
||||||
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
|
|||||||
|
|
||||||
export async function listAccountPasskeyCredentialsByUserId(
|
export async function listAccountPasskeyCredentialsByUserId(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<AccountPasskeyCredential[]> {
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||||
.bind(userId)
|
.bind(userId, purpose)
|
||||||
.all<any>();
|
.all<any>();
|
||||||
return (rows.results || []).map(mapCredentialRow);
|
return (rows.results || []).map(mapCredentialRow);
|
||||||
}
|
}
|
||||||
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
|
|||||||
|
|
||||||
export async function countAccountPasskeyCredentialsByUserId(
|
export async function countAccountPasskeyCredentialsByUserId(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||||
.bind(userId)
|
.bind(userId, purpose)
|
||||||
.first<{ count: number }>();
|
.first<{ count: number }>();
|
||||||
return Number(row?.count || 0);
|
return Number(row?.count || 0);
|
||||||
}
|
}
|
||||||
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
|
|||||||
export async function deleteAccountPasskeyCredential(
|
export async function deleteAccountPasskeyCredential(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
id: string
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||||
.bind(userId, id)
|
.bind(userId, id, purpose)
|
||||||
.run();
|
.run();
|
||||||
return Number(result.meta.changes || 0) > 0;
|
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 INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
'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, ' +
|
'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, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 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 ON webauthn_credentials(user_id)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
'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
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// 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;
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
@@ -398,8 +398,11 @@ export class StorageService {
|
|||||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
async getAccountPasskeyCredentialsByUserId(
|
||||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
|
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||||
@@ -410,8 +413,11 @@ export class StorageService {
|
|||||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
async countAccountPasskeyCredentialsByUserId(
|
||||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<number> {
|
||||||
|
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAccountPasskeyCounter(
|
async updateAccountPasskeyCounter(
|
||||||
@@ -442,8 +448,12 @@ export class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
async deleteAccountPasskeyCredential(
|
||||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<boolean> {
|
||||||
|
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||||
|
|||||||
+7
-1
@@ -252,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
|||||||
export interface AccountPasskeyCredential {
|
export interface AccountPasskeyCredential {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
purpose: 'login' | 'twoFactor';
|
||||||
name: string;
|
name: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
@@ -267,7 +268,12 @@ export interface AccountPasskeyCredential {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
export type AccountPasskeyChallengeScope =
|
||||||
|
| 'Authentication'
|
||||||
|
| 'CreateCredential'
|
||||||
|
| 'UpdateKeySet'
|
||||||
|
| 'TwoFactorAuthentication'
|
||||||
|
| 'TwoFactorCreate';
|
||||||
|
|
||||||
export interface AccountPasskeyChallenge {
|
export interface AccountPasskeyChallenge {
|
||||||
challengeHash: string;
|
challengeHash: string;
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
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(
|
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 PendingPasskeyPassword,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
} from '@/lib/app-auth';
|
} from '@/lib/app-auth';
|
||||||
|
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
|
||||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||||
import useAdminActions from '@/hooks/useAdminActions';
|
import useAdminActions from '@/hooks/useAdminActions';
|
||||||
import useBackupActions from '@/hooks/useBackupActions';
|
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_AUTH_REQUEST_RESPONSE = 16;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
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 ThemePreference = 'system' | 'light' | 'dark';
|
||||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
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() {
|
async function handleTotpVerify() {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
if (!totpCode.trim()) {
|
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||||
pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setTotpSubmitting(true);
|
setTotpSubmitting(true);
|
||||||
try {
|
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);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setTotpSubmitting(false);
|
setTotpSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -952,11 +974,13 @@ export default function App() {
|
|||||||
onCancelConfirm={() => {}}
|
onCancelConfirm={() => {}}
|
||||||
pendingTotpOpen={false}
|
pendingTotpOpen={false}
|
||||||
pendingTotpProviderType={0}
|
pendingTotpProviderType={0}
|
||||||
|
pendingTotpAvailableProviders={[]}
|
||||||
totpCode=""
|
totpCode=""
|
||||||
rememberDevice={false}
|
rememberDevice={false}
|
||||||
onTotpCodeChange={() => {}}
|
onTotpCodeChange={() => {}}
|
||||||
onRememberDeviceChange={() => {}}
|
onRememberDeviceChange={() => {}}
|
||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
|
onSelectTotpProvider={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
totpSubmitting={false}
|
totpSubmitting={false}
|
||||||
@@ -1957,6 +1981,7 @@ export default function App() {
|
|||||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||||
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||||
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||||
|
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
|
||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
sessionTimeoutAction,
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
@@ -2014,6 +2039,10 @@ export default function App() {
|
|||||||
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||||
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||||
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||||
|
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
|
||||||
|
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
|
||||||
|
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
|
||||||
|
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
onGetApiKey: accountSecurityActions.getApiKey,
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
@@ -2219,11 +2248,13 @@ export default function App() {
|
|||||||
onCancelConfirm={() => setConfirm(null)}
|
onCancelConfirm={() => setConfirm(null)}
|
||||||
pendingTotpOpen={!!pendingTotp}
|
pendingTotpOpen={!!pendingTotp}
|
||||||
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||||
|
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
|
||||||
totpCode={totpCode}
|
totpCode={totpCode}
|
||||||
rememberDevice={rememberDevice}
|
rememberDevice={rememberDevice}
|
||||||
onTotpCodeChange={setTotpCode}
|
onTotpCodeChange={setTotpCode}
|
||||||
onRememberDeviceChange={setRememberDevice}
|
onRememberDeviceChange={setRememberDevice}
|
||||||
onConfirmTotp={() => void handleTotpVerify()}
|
onConfirmTotp={() => void handleTotpVerify()}
|
||||||
|
onSelectTotpProvider={handleSelectTotpProvider}
|
||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -2279,11 +2310,13 @@ export default function App() {
|
|||||||
onCancelConfirm={() => setConfirm(null)}
|
onCancelConfirm={() => setConfirm(null)}
|
||||||
pendingTotpOpen={false}
|
pendingTotpOpen={false}
|
||||||
pendingTotpProviderType={0}
|
pendingTotpProviderType={0}
|
||||||
|
pendingTotpAvailableProviders={[]}
|
||||||
totpCode=""
|
totpCode=""
|
||||||
rememberDevice={false}
|
rememberDevice={false}
|
||||||
onTotpCodeChange={() => {}}
|
onTotpCodeChange={() => {}}
|
||||||
onRememberDeviceChange={() => {}}
|
onRememberDeviceChange={() => {}}
|
||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
|
onSelectTotpProvider={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
totpSubmitting={false}
|
totpSubmitting={false}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -22,11 +23,13 @@ interface AppGlobalOverlaysProps {
|
|||||||
onCancelConfirm: () => void;
|
onCancelConfirm: () => void;
|
||||||
pendingTotpOpen: boolean;
|
pendingTotpOpen: boolean;
|
||||||
pendingTotpProviderType?: number;
|
pendingTotpProviderType?: number;
|
||||||
|
pendingTotpAvailableProviders?: number[];
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
rememberDevice: boolean;
|
rememberDevice: boolean;
|
||||||
onTotpCodeChange: (value: string) => void;
|
onTotpCodeChange: (value: string) => void;
|
||||||
onRememberDeviceChange: (checked: boolean) => void;
|
onRememberDeviceChange: (checked: boolean) => void;
|
||||||
onConfirmTotp: () => void;
|
onConfirmTotp: () => void;
|
||||||
|
onSelectTotpProvider: (providerType: number) => void;
|
||||||
onCancelTotp: () => void;
|
onCancelTotp: () => void;
|
||||||
onUseRecoveryCode: () => void;
|
onUseRecoveryCode: () => void;
|
||||||
totpSubmitting: boolean;
|
totpSubmitting: boolean;
|
||||||
@@ -38,8 +41,40 @@ interface AppGlobalOverlaysProps {
|
|||||||
disableTotpSubmitting: boolean;
|
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) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -57,10 +92,11 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.pendingTotpOpen}
|
open={props.pendingTotpOpen}
|
||||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : t('txt_two_step_verification')}
|
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') : t('txt_password_is_already_verified')}
|
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')}
|
confirmText={t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
hideCancel
|
||||||
|
closeButton
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
confirmDisabled={props.totpSubmitting}
|
confirmDisabled={props.totpSubmitting}
|
||||||
cancelDisabled={props.totpSubmitting}
|
cancelDisabled={props.totpSubmitting}
|
||||||
@@ -69,16 +105,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
afterActions={(
|
afterActions={(
|
||||||
<div className="dialog-extra">
|
<div className="dialog-extra">
|
||||||
<div className="dialog-divider" />
|
<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}>
|
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||||
{t('txt_use_recovery_code')}
|
{t('txt_use_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isWebAuthn ? (
|
||||||
|
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||||
|
) : (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
<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)} />
|
<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>
|
||||||
|
)}
|
||||||
<label className="check-line check-line-compact">
|
<label className="check-line check-line-compact">
|
||||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
<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>
|
<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')}
|
title={t('txt_disable_totp')}
|
||||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||||
confirmText={t('txt_disable_totp')}
|
confirmText={t('txt_disable_totp')}
|
||||||
cancelText={t('txt_cancel')}
|
hideCancel
|
||||||
|
closeButton
|
||||||
danger
|
danger
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
confirmDisabled={props.disableTotpSubmitting}
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
|||||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
@@ -56,6 +56,7 @@ export interface AppMainRoutesProps {
|
|||||||
adminError: string;
|
adminError: string;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
yubikeyEnabled: boolean;
|
yubikeyEnabled: boolean;
|
||||||
|
passkey2faEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
@@ -118,6 +119,10 @@ export interface AppMainRoutesProps {
|
|||||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
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>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
@@ -276,6 +281,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
profile={props.profile}
|
profile={props.profile}
|
||||||
totpEnabled={props.totpEnabled}
|
totpEnabled={props.totpEnabled}
|
||||||
yubikeyEnabled={props.yubikeyEnabled}
|
yubikeyEnabled={props.yubikeyEnabled}
|
||||||
|
passkey2faEnabled={props.passkey2faEnabled}
|
||||||
themePreference={props.themePreference}
|
themePreference={props.themePreference}
|
||||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||||
@@ -290,6 +296,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||||
onDisableYubiKey={props.onDisableYubiKey}
|
onDisableYubiKey={props.onDisableYubiKey}
|
||||||
|
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||||
|
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||||
|
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||||
|
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
onGetApiKey={props.onGetApiKey}
|
onGetApiKey={props.onGetApiKey}
|
||||||
onRotateApiKey={props.onRotateApiKey}
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
|
|||||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||||
const starBoxes = [1, 2, 3, 4];
|
|
||||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="not-found-page">
|
<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">
|
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||||
<div className="not-found-brand">
|
<div className="not-found-brand">
|
||||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||||
</div>
|
</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-copy">
|
||||||
<div className="not-found-code">404</div>
|
<div className="not-found-code">404</div>
|
||||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
<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 { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
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 { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ interface SettingsPageProps {
|
|||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
yubikeyEnabled: boolean;
|
yubikeyEnabled: boolean;
|
||||||
|
passkey2faEnabled: boolean;
|
||||||
themePreference: ThemePreference;
|
themePreference: ThemePreference;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
@@ -24,6 +25,10 @@ interface SettingsPageProps {
|
|||||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
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>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
@@ -47,6 +52,7 @@ type MasterPasswordPromptAction =
|
|||||||
| 'rotateApiKey'
|
| 'rotateApiKey'
|
||||||
| 'manageTotp'
|
| 'manageTotp'
|
||||||
| 'manageYubiKey'
|
| 'manageYubiKey'
|
||||||
|
| 'managePasskey2fa'
|
||||||
| 'createPasskey'
|
| 'createPasskey'
|
||||||
| 'enablePasskeyDirectUnlock'
|
| 'enablePasskeyDirectUnlock'
|
||||||
| 'deletePasskey';
|
| 'deletePasskey';
|
||||||
@@ -143,6 +149,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||||
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
|
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
|
||||||
const [yubiKeySubmitting, setYubiKeySubmitting] = 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 [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
|
||||||
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
||||||
const [totpManagePassword, setTotpManagePassword] = useState('');
|
const [totpManagePassword, setTotpManagePassword] = useState('');
|
||||||
@@ -172,6 +184,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
||||||
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
|
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTwoFactorPasskeyEnabled(props.passkey2faEnabled);
|
||||||
|
}, [props.passkey2faEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshAccountPasskeys();
|
void refreshAccountPasskeys();
|
||||||
}, [props.profile.id]);
|
}, [props.profile.id]);
|
||||||
@@ -250,6 +266,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
applyYubiKeySettings(settings);
|
applyYubiKeySettings(settings);
|
||||||
setYubiKeyConfigOpen(false);
|
setYubiKeyConfigOpen(false);
|
||||||
setYubiKeyDialogOpen(true);
|
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') {
|
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||||
setCreatePasskeyMasterPassword(masterPassword);
|
setCreatePasskeyMasterPassword(masterPassword);
|
||||||
@@ -284,6 +306,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
? t('txt_totp')
|
? t('txt_totp')
|
||||||
: masterPasswordPrompt === 'manageYubiKey'
|
: masterPasswordPrompt === 'manageYubiKey'
|
||||||
? 'YubiKey'
|
? 'YubiKey'
|
||||||
|
: masterPasswordPrompt === 'managePasskey2fa'
|
||||||
|
? t('txt_two_step_passkeys')
|
||||||
: masterPasswordPrompt === 'createPasskey'
|
: masterPasswordPrompt === 'createPasskey'
|
||||||
? t('txt_add_account_passkey')
|
? t('txt_add_account_passkey')
|
||||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||||
@@ -327,7 +351,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
||||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||||
setYubiKeyNfc(false);
|
setYubiKeyNfc(false);
|
||||||
setYubiKeyEnabled(false);
|
|
||||||
setYubiKeyYubicoConfigured(false);
|
setYubiKeyYubicoConfigured(false);
|
||||||
setYubiKeyYubicoClientId('');
|
setYubiKeyYubicoClientId('');
|
||||||
setYubiKeyYubicoSecretKey('');
|
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> {
|
async function refreshTwoFactorStatus(): Promise<void> {
|
||||||
if (twoFactorStatusRefreshing) return;
|
if (twoFactorStatusRefreshing) return;
|
||||||
setTwoFactorStatusRefreshing(true);
|
setTwoFactorStatusRefreshing(true);
|
||||||
@@ -726,10 +803,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
<KeyRound size={28} />
|
<KeyRound size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div className="two-step-provider-copy">
|
<div className="two-step-provider-copy">
|
||||||
|
<div className="two-step-provider-title">
|
||||||
<strong>{t('txt_passkeys')}</strong>
|
<strong>{t('txt_passkeys')}</strong>
|
||||||
|
{twoFactorPasskeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
|
||||||
|
</div>
|
||||||
<span>{t('txt_passkey_provider_help')}</span>
|
<span>{t('txt_passkey_provider_help')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary" disabled>
|
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('managePasskey2fa')}>
|
||||||
{t('txt_manage')}
|
{t('txt_manage')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1028,13 +1108,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ConfirmDialog>
|
</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
|
<ConfirmDialog
|
||||||
open={recoveryCodeDialogOpen}
|
open={recoveryCodeDialogOpen}
|
||||||
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
||||||
message={t('txt_your_two_step_recovery_code')}
|
message={t('txt_your_two_step_recovery_code')}
|
||||||
hideConfirm
|
hideConfirm
|
||||||
|
hideCancel
|
||||||
closeButton
|
closeButton
|
||||||
cancelText={t('txt_close')}
|
|
||||||
onConfirm={() => {}}
|
onConfirm={() => {}}
|
||||||
onCancel={() => setRecoveryCodeDialogOpen(false)}
|
onCancel={() => setRecoveryCodeDialogOpen(false)}
|
||||||
afterActions={(
|
afterActions={(
|
||||||
|
|||||||
@@ -7,19 +7,24 @@ import {
|
|||||||
deleteAuthorizedDevices,
|
deleteAuthorizedDevices,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||||
|
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
|
||||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||||
|
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
|
||||||
disableYubiKeyOtp,
|
disableYubiKeyOtp,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
getAccountPasskeyAttestationOptions,
|
getAccountPasskeyAttestationOptions,
|
||||||
getAccountPasskeyUpdateAssertionOptions,
|
getAccountPasskeyUpdateAssertionOptions,
|
||||||
getTotpRecoveryCode,
|
getTotpRecoveryCode,
|
||||||
|
getTwoFactorPasskeyChallenge,
|
||||||
|
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
|
||||||
getYubiKeyOtpSettings,
|
getYubiKeyOtpSettings,
|
||||||
listAccountPasskeys,
|
listAccountPasskeys,
|
||||||
rotateApiKey,
|
rotateApiKey,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
saveAccountPasskey,
|
saveAccountPasskey,
|
||||||
|
saveTwoFactorPasskey,
|
||||||
saveYubiKeyOtpApiCredentials,
|
saveYubiKeyOtpApiCredentials,
|
||||||
saveYubiKeyOtpSettings,
|
saveYubiKeyOtpSettings,
|
||||||
setTotp,
|
setTotp,
|
||||||
@@ -33,11 +38,12 @@ import {
|
|||||||
buildAccountPasskeyPrfKeySet,
|
buildAccountPasskeyPrfKeySet,
|
||||||
buildAccountPasskeyPrfKeySetFromPrfKey,
|
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||||
createAccountPasskeyCredential,
|
createAccountPasskeyCredential,
|
||||||
|
createTwoFactorPasskeyCredential,
|
||||||
} from '@/lib/account-passkeys';
|
} from '@/lib/account-passkeys';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
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;
|
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'));
|
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> {
|
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
const normalized = String(masterPassword || '');
|
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 {
|
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
const [type, payload] = text.split('.');
|
const [type, payload] = text.split('.');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
SessionState,
|
SessionState,
|
||||||
TokenError,
|
TokenError,
|
||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
|
TwoFactorPasskeySettings,
|
||||||
YubiKeyOtpSettings,
|
YubiKeyOtpSettings,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
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(
|
export async function verifyMasterPassword(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
masterPasswordHash: string
|
masterPasswordHash: string
|
||||||
@@ -913,7 +1007,7 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
|
|||||||
return stamp;
|
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');
|
const resp = await authedFetch('/api/two-factor');
|
||||||
if (!resp.ok) throw new Error('Failed to load two-factor status');
|
if (!resp.ok) throw new Error('Failed to load two-factor status');
|
||||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||||
@@ -926,6 +1020,7 @@ export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Prom
|
|||||||
return {
|
return {
|
||||||
totpEnabled: enabledTypes.has(0),
|
totpEnabled: enabledTypes.has(0),
|
||||||
yubikeyEnabled: enabledTypes.has(3),
|
yubikeyEnabled: enabledTypes.has(3),
|
||||||
|
passkeyEnabled: enabledTypes.has(7),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-20
@@ -35,6 +35,9 @@ export interface PendingTotp {
|
|||||||
masterKey: Uint8Array;
|
masterKey: Uint8Array;
|
||||||
kdfIterations: number;
|
kdfIterations: number;
|
||||||
providerType: number;
|
providerType: number;
|
||||||
|
providerData?: unknown;
|
||||||
|
availableProviders: number[];
|
||||||
|
providerDataByType: Record<number, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingPasskeyPassword {
|
export interface PendingPasskeyPassword {
|
||||||
@@ -73,27 +76,94 @@ export interface CompletedLogin {
|
|||||||
|
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
|
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
|
||||||
|
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||||
|
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||||
|
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||||
|
] as const;
|
||||||
|
|
||||||
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||||
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || 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 (Array.isArray(providers)) {
|
||||||
if (providers.some((provider: any) => Number(
|
for (const provider of providers) {
|
||||||
provider && typeof provider === 'object' ? provider.Type ?? provider.type : provider
|
const providerType = twoFactorProviderTypeFromValue(provider);
|
||||||
) === TWO_FACTOR_PROVIDER_YUBIKEY)) {
|
if (providerType != null) providerTypes.push(providerType);
|
||||||
return TWO_FACTOR_PROVIDER_YUBIKEY;
|
|
||||||
}
|
}
|
||||||
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
} else if (providers && typeof providers === 'object') {
|
||||||
}
|
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
|
||||||
if (providers && typeof providers === 'object') {
|
if (!value) continue;
|
||||||
const record = providers as Record<string, unknown>;
|
const providerType = twoFactorProviderTypeFromValue(key);
|
||||||
if (record[String(TWO_FACTOR_PROVIDER_YUBIKEY)] || record.YubiKey || record.Yubikey) {
|
if (providerType != null) providerTypes.push(providerType);
|
||||||
return TWO_FACTOR_PROVIDER_YUBIKEY;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 =
|
export type PasswordLoginResult =
|
||||||
@@ -438,8 +508,12 @@ export async function performPasswordLogin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
const tokenError = token as TwoFactorTokenError;
|
||||||
if (tokenError.TwoFactorProviders) {
|
const providers = readTwoFactorProviders(tokenError);
|
||||||
|
if (providers) {
|
||||||
|
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||||
|
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||||
|
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||||
return {
|
return {
|
||||||
kind: 'totp',
|
kind: 'totp',
|
||||||
pendingTotp: {
|
pendingTotp: {
|
||||||
@@ -447,7 +521,10 @@ export async function performPasswordLogin(
|
|||||||
passwordHash: derived.hash,
|
passwordHash: derived.hash,
|
||||||
masterKey: derived.masterKey,
|
masterKey: derived.masterKey,
|
||||||
kdfIterations: derived.kdfIterations,
|
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);
|
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
|
||||||
}
|
}
|
||||||
const tokenError = token as { error_description?: string; error?: string };
|
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(
|
export async function performRecoverTwoFactorLogin(
|
||||||
@@ -608,7 +688,7 @@ export async function performUnlock(
|
|||||||
return unlockOffline();
|
return unlockOffline();
|
||||||
}
|
}
|
||||||
|
|
||||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
let token: TokenSuccess | TwoFactorTokenError;
|
||||||
try {
|
try {
|
||||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||||
useRememberToken: true,
|
useRememberToken: true,
|
||||||
@@ -630,8 +710,12 @@ export async function performUnlock(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
const tokenError = token as TwoFactorTokenError;
|
||||||
if (tokenError.TwoFactorProviders) {
|
const providers = readTwoFactorProviders(tokenError);
|
||||||
|
if (providers) {
|
||||||
|
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||||
|
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||||
|
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||||
return {
|
return {
|
||||||
kind: 'totp',
|
kind: 'totp',
|
||||||
pendingTotp: {
|
pendingTotp: {
|
||||||
@@ -639,7 +723,10 @@ export async function performUnlock(
|
|||||||
passwordHash: derived.hash,
|
passwordHash: derived.hash,
|
||||||
masterKey: derived.masterKey,
|
masterKey: derived.masterKey,
|
||||||
kdfIterations: derived.kdfIterations,
|
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,
|
adminLoading: false,
|
||||||
adminError: '',
|
adminError: '',
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
|
passkey2faEnabled: false,
|
||||||
authorizedDevices: state.authorizedDevices,
|
authorizedDevices: state.authorizedDevices,
|
||||||
authorizedDevicesLoading: false,
|
authorizedDevicesLoading: false,
|
||||||
authorizedDevicesError: '',
|
authorizedDevicesError: '',
|
||||||
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onSavePasswordHint: readonly,
|
onSavePasswordHint: readonly,
|
||||||
onEnableTotp: readonly,
|
onEnableTotp: readonly,
|
||||||
onOpenDisableTotp: readonlyVoid,
|
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,
|
onGetRecoveryCode: readonlyString,
|
||||||
onGetApiKey: readonlyString,
|
onGetApiKey: readonlyString,
|
||||||
onRotateApiKey: 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_password_hint_too_long": "Password hint must be 120 characters or fewer",
|
||||||
"txt_passkey": "Passkey",
|
"txt_passkey": "Passkey",
|
||||||
"txt_passkeys": "Passkeys",
|
"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_passkey_created_at_value": "Created on {value}",
|
||||||
"txt_account_passkey": "Account passkey",
|
"txt_account_passkey": "Account passkey",
|
||||||
"txt_account_passkeys": "Account passkeys",
|
"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_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
|
||||||
"txt_passkey": "Clave de acceso",
|
"txt_passkey": "Clave de acceso",
|
||||||
"txt_passkeys": "Claves 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_passkey_created_at_value": "Creado el {value}",
|
||||||
"txt_account_passkey": "Clave de acceso de cuenta",
|
"txt_account_passkey": "Clave de acceso de cuenta",
|
||||||
"txt_account_passkeys": "Claves 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_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
|
||||||
"txt_passkey": "Ключ доступа",
|
"txt_passkey": "Ключ доступа",
|
||||||
"txt_passkeys": "Ключи доступа",
|
"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_passkey_created_at_value": "Создано {value}",
|
||||||
"txt_account_passkey": "Ключ доступа аккаунта",
|
"txt_account_passkey": "Ключ доступа аккаунта",
|
||||||
"txt_account_passkeys": "Ключи доступа аккаунта",
|
"txt_account_passkeys": "Ключи доступа аккаунта",
|
||||||
|
|||||||
@@ -806,6 +806,23 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
|
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
|
||||||
"txt_passkey": "通行密钥",
|
"txt_passkey": "通行密钥",
|
||||||
"txt_passkeys": "通行密钥",
|
"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_passkey_created_at_value": "创建于 {value}",
|
||||||
"txt_account_passkey": "账号通行密钥",
|
"txt_account_passkey": "账号通行密钥",
|
||||||
"txt_account_passkeys": "账号通行密钥",
|
"txt_account_passkeys": "账号通行密钥",
|
||||||
|
|||||||
@@ -806,6 +806,23 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
|
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
|
||||||
"txt_passkey": "通行密鑰",
|
"txt_passkey": "通行密鑰",
|
||||||
"txt_passkeys": "通行密鑰",
|
"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_passkey_created_at_value": "創建於 {value}",
|
||||||
"txt_account_passkey": "賬號通行密鑰",
|
"txt_account_passkey": "賬號通行密鑰",
|
||||||
"txt_account_passkeys": "賬號通行密鑰",
|
"txt_account_passkeys": "賬號通行密鑰",
|
||||||
|
|||||||
@@ -350,6 +350,17 @@ export interface AccountPasskeyCredential {
|
|||||||
revisionDate?: string;
|
revisionDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorPasskeyCredential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
migrated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorPasskeySettings {
|
||||||
|
enabled: boolean;
|
||||||
|
keys: TwoFactorPasskeyCredential[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthRequest {
|
export interface AuthRequest {
|
||||||
id: string;
|
id: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
|||||||
@@ -30,14 +30,11 @@
|
|||||||
|
|
||||||
.not-found-page {
|
.not-found-page {
|
||||||
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
||||||
background:
|
background: var(--surface);
|
||||||
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%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-shell {
|
.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;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -356,7 +353,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.not-found-logo {
|
.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));
|
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,17 +374,16 @@
|
|||||||
|
|
||||||
.not-found-copy {
|
.not-found-copy {
|
||||||
@apply grid justify-items-center gap-3;
|
@apply grid justify-items-center gap-3;
|
||||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-shell h1 {
|
.not-found-shell h1 {
|
||||||
@apply m-0 text-3xl font-extrabold leading-tight;
|
@apply m-0 text-3xl font-extrabold leading-tight;
|
||||||
color: #f8fbff;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-shell p {
|
.not-found-shell p {
|
||||||
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
||||||
color: rgba(220, 232, 251, 0.82);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-action {
|
.not-found-action {
|
||||||
@@ -396,10 +392,7 @@
|
|||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.not-found-page {
|
.not-found-page {
|
||||||
background:
|
background: var(--surface);
|
||||||
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%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-shell {
|
.not-found-shell {
|
||||||
|
|||||||
@@ -1352,7 +1352,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-module-head {
|
.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 {
|
.settings-module-head h3 {
|
||||||
@@ -1382,10 +1382,37 @@
|
|||||||
accent-color: var(--primary);
|
accent-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-passkey-list,
|
||||||
.account-passkeys-list {
|
.account-passkeys-list {
|
||||||
@apply mt-3 grid gap-2;
|
@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 {
|
.account-passkey-row {
|
||||||
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
|
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
@@ -1393,6 +1420,17 @@
|
|||||||
background: var(--panel);
|
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 {
|
.account-passkey-main {
|
||||||
@apply grid min-w-0 gap-1;
|
@apply grid min-w-0 gap-1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog-extra {
|
.dialog-extra {
|
||||||
@apply mt-2;
|
@apply mt-2 grid gap-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-divider {
|
.dialog-divider {
|
||||||
@@ -99,6 +99,25 @@
|
|||||||
background: var(--line);
|
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 {
|
.import-summary-dialog {
|
||||||
@apply relative max-w-[520px] pt-4 text-left;
|
@apply relative max-w-[520px] pt-4 text-left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1143,6 +1143,15 @@
|
|||||||
padding: 10px;
|
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 {
|
.account-passkey-status {
|
||||||
justify-self: flex-start;
|
justify-self: flex-start;
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1161,15 @@
|
|||||||
width: 100%;
|
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-module .totp-grid,
|
||||||
.settings-submodule .totp-grid {
|
.settings-submodule .totp-grid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user