feat: implement device login approval system

Add a complete device authentication approval flow that allows users to approve login requests from new devices on their already-authenticated devices.

Core features:
- Create authentication requests when logging in from new devices
- Display pending requests with device info, IP address, and fingerprint phrases
- Approve or deny requests from web interface with real-time notifications
- Support multiple auth request types (authenticate & unlock, unlock only)
- Automatic expiration and cleanup of stale requests

Backend changes:
- Add auth_requests table with proper indexes for efficient queries
- Implement full CRUD API for authentication requests
- Add notification hub integration for real-time updates
- Add device fingerprint phrase generation for security verification

Frontend changes:
- Add AuthRequestApprovalDialog component for approving/denying requests
- Add PendingAuthRequestsPanel component to display and manage pending requests
- Integrate panels into Security and Settings pages
- Add fingerprint wordlist for generating human-readable verification phrases
- Update i18n translations for all supported languages

Security considerations:
- Access code verification to prevent unauthorized access
- Device fingerprint validation for additional security layer
- IP address and country tracking for audit purposes
- Automatic expiration of old requests (15 minutes)
- Only most recent request per device can be approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shuaiplus
2026-06-12 13:12:11 +08:00
parent e9aef72df7
commit c652cc1533
27 changed files with 9187 additions and 92 deletions
+128
View File
@@ -0,0 +1,128 @@
import { base64ToBytes, bytesToBase64, hkdfExpand, toBufferSource } from '@/lib/crypto';
import { EFFLongWordList } from '@/lib/fingerprint-wordlist';
import { t } from '@/lib/i18n';
import type { AuthRequest, ListResponse, SessionState } from '@/lib/types';
import type { AuthedFetch } from './shared';
import { parseErrorMessage, parseJson } from './shared';
function readResponseProperty<T>(source: Record<string, any>, camel: string, pascal: string, fallback: T): T {
return (source[camel] ?? source[pascal] ?? fallback) as T;
}
function normalizeAuthRequest(raw: Record<string, any>): AuthRequest {
return {
id: String(readResponseProperty(raw, 'id', 'Id', '')),
publicKey: String(readResponseProperty(raw, 'publicKey', 'PublicKey', '')),
requestDeviceType: readResponseProperty(raw, 'requestDeviceType', 'RequestDeviceType', null),
requestDeviceTypeValue: readResponseProperty(raw, 'requestDeviceTypeValue', 'RequestDeviceTypeValue', null),
requestDeviceIdentifier: String(readResponseProperty(raw, 'requestDeviceIdentifier', 'RequestDeviceIdentifier', '')),
requestIpAddress: readResponseProperty(raw, 'requestIpAddress', 'RequestIpAddress', null),
requestCountryName: readResponseProperty(raw, 'requestCountryName', 'RequestCountryName', null),
key: readResponseProperty(raw, 'key', 'Key', null),
creationDate: String(readResponseProperty(raw, 'creationDate', 'CreationDate', '')),
requestApproved: readResponseProperty(raw, 'requestApproved', 'RequestApproved', null),
responseDate: readResponseProperty(raw, 'responseDate', 'ResponseDate', null),
deviceId: readResponseProperty(raw, 'deviceId', 'DeviceId', null),
requestDeviceId: readResponseProperty(raw, 'requestDeviceId', 'RequestDeviceId', null),
};
}
async function withFingerprintPhrase(email: string, request: AuthRequest): Promise<AuthRequest> {
if (!request.publicKey) return request;
try {
return {
...request,
fingerprintPhrase: await getFingerprintPhrase(email, base64ToBytes(request.publicKey)),
};
} catch {
return request;
}
}
export async function listPendingAuthRequests(authedFetch: AuthedFetch, email: string): Promise<AuthRequest[]> {
const resp = await authedFetch('/api/auth-requests/pending');
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_requests_load_failed')));
const body = await parseJson<ListResponse<Record<string, any>> & { Data?: Record<string, any>[] }>(resp);
const rows = (body?.data || body?.Data || []).map(normalizeAuthRequest);
return Promise.all(rows.map((row) => withFingerprintPhrase(email, row)));
}
export async function respondToAuthRequest(
authedFetch: AuthedFetch,
requestId: string,
payload: {
key?: string | null;
masterPasswordHash?: string | null;
deviceIdentifier: string;
requestApproved: boolean;
}
): Promise<AuthRequest> {
const resp = await authedFetch(`/api/auth-requests/${encodeURIComponent(requestId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_request_update_failed')));
const body = await parseJson<Record<string, any>>(resp);
if (!body) throw new Error(t('txt_auth_request_update_failed'));
return normalizeAuthRequest(body);
}
export function isPendingAuthRequest(request: AuthRequest): boolean {
if (!request.id || !request.creationDate) return false;
if (request.responseDate) return false;
const createdAt = new Date(request.creationDate).getTime();
if (!Number.isFinite(createdAt)) return true;
return Date.now() - createdAt < 15 * 60 * 1000;
}
export async function encryptSessionUserKeyForAuthRequest(session: SessionState, authRequest: AuthRequest): Promise<string> {
if (!session.symEncKey || !session.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!authRequest.publicKey) throw new Error(t('txt_auth_request_missing_public_key'));
const userKeyBytes = new Uint8Array(64);
userKeyBytes.set(base64ToBytes(session.symEncKey), 0);
userKeyBytes.set(base64ToBytes(session.symMacKey), 32);
const publicKey = await crypto.subtle.importKey(
'spki',
toBufferSource(base64ToBytes(authRequest.publicKey)),
{ name: 'RSA-OAEP', hash: 'SHA-1' },
false,
['encrypt']
);
const encryptedBytes = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
toBufferSource(userKeyBytes)
));
return `4.${bytesToBase64(encryptedBytes)}`;
}
export async function getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
const keyFingerprint = new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(publicKey)));
const userFingerprint = await hkdfExpand(keyFingerprint, email.toLowerCase(), 32);
return hashPhrase(userFingerprint).join('-');
}
function hashPhrase(hash: Uint8Array, minimumEntropy = 64): string[] {
const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2);
let numWords = Math.ceil(minimumEntropy / entropyPerWord);
if (numWords * entropyPerWord > hash.length * 4) {
throw new Error('Output entropy of hash function is too small');
}
let hashNumber = 0n;
for (const byte of hash) {
hashNumber = (hashNumber * 256n) + BigInt(byte);
}
const phrase: string[] = [];
const wordCount = BigInt(EFFLongWordList.length);
while (numWords > 0) {
const remainder = Number(hashNumber % wordCount);
hashNumber /= wordCount;
phrase.push(EFFLongWordList[remainder]);
numWords -= 1;
}
return phrase;
}
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1172,7 +1172,22 @@ const en: Record<string, string> = {
"txt_target": "Target",
"txt_time": "Time",
"txt_time_range": "Time range",
"txt_remove_domain": "Remove domain"
"txt_remove_domain": "Remove domain",
"txt_approve_device_login": "Approve device login",
"txt_auth_request_approve_message": "Unlock Bitwarden on your device or approve from the web app. Before approving, make sure the fingerprint phrase matches the one below.",
"txt_approve": "Approve",
"txt_approving": "Approving...",
"txt_deny": "Deny",
"txt_later": "Later",
"txt_pending_device_logins": "Pending device logins",
"txt_no_pending_device_logins": "No pending device logins",
"txt_fingerprint_phrase": "Fingerprint phrase",
"txt_auth_requests_load_failed": "Failed to load device login requests",
"txt_auth_request_update_failed": "Failed to update device login request",
"txt_auth_request_approved": "Device login approved",
"txt_auth_request_denied": "Device login denied",
"txt_auth_request_missing_public_key": "Device login request is missing a public key",
"txt_ip_address": "IP address"
};
export default en;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const es: Record<string, string> = {
"txt_target": "Destino",
"txt_time": "Hora",
"txt_time_range": "Rango de tiempo",
"txt_remove_domain": "Quitar dominio"
"txt_remove_domain": "Quitar dominio",
"txt_approve_device_login": "Aprobar inicio de sesión con dispositivo",
"txt_auth_request_approve_message": "Desbloquee Bitwarden en su dispositivo o apruebe desde la aplicación web. Antes de aprobar, asegúrese de que la frase de huella coincida con la siguiente.",
"txt_fingerprint_phrase": "Frase de huella",
"txt_ip_address": "Dirección IP",
"txt_approve": "Aprobar",
"txt_approving": "Aprobando...",
"txt_deny": "Denegar",
"txt_later": "Más tarde",
"txt_pending_device_logins": "Inicios de sesión con dispositivo pendientes",
"txt_no_pending_device_logins": "No hay inicios de sesión con dispositivo pendientes",
"txt_auth_requests_load_failed": "No se pudieron cargar las solicitudes de inicio de sesión con dispositivo",
"txt_auth_request_update_failed": "No se pudo actualizar la solicitud de inicio de sesión con dispositivo",
"txt_auth_request_approved": "Inicio de sesión con dispositivo aprobado",
"txt_auth_request_denied": "Inicio de sesión con dispositivo denegado",
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
};
export default es;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const ru: Record<string, string> = {
"txt_target": "Цель",
"txt_time": "Время",
"txt_time_range": "Период",
"txt_remove_domain": "Удалить домен"
"txt_remove_domain": "Удалить домен",
"txt_approve_device_login": "Подтвердить вход с устройства",
"txt_auth_request_approve_message": "Разблокируйте Bitwarden на устройстве или подтвердите вход через веб-приложение. Перед подтверждением убедитесь, что фраза отпечатка совпадает с указанной ниже.",
"txt_fingerprint_phrase": "Фраза отпечатка",
"txt_ip_address": "IP-адрес",
"txt_approve": "Подтвердить",
"txt_approving": "Подтверждение...",
"txt_deny": "Отклонить",
"txt_later": "Позже",
"txt_pending_device_logins": "Ожидающие входы с устройств",
"txt_no_pending_device_logins": "Нет ожидающих входов с устройств",
"txt_auth_requests_load_failed": "Не удалось загрузить запросы входа с устройств",
"txt_auth_request_update_failed": "Не удалось обновить запрос входа с устройства",
"txt_auth_request_approved": "Вход с устройства подтвержден",
"txt_auth_request_denied": "Вход с устройства отклонен",
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
};
export default ru;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const zhCN: Record<string, string> = {
"txt_target": "目标",
"txt_time": "时间",
"txt_time_range": "时间范围",
"txt_remove_domain": "移除域名"
"txt_remove_domain": "移除域名",
"txt_approve_device_login": "批准设备登录",
"txt_auth_request_approve_message": "解锁您设备上的 Bitwarden,或通过网页 App 批准。批准前,请确保指纹短语与下面的相匹配。",
"txt_approve": "批准",
"txt_approving": "正在批准...",
"txt_deny": "拒绝",
"txt_later": "稍后",
"txt_pending_device_logins": "待处理设备登录",
"txt_no_pending_device_logins": "没有待处理设备登录",
"txt_fingerprint_phrase": "指纹短语",
"txt_auth_requests_load_failed": "加载设备登录请求失败",
"txt_auth_request_update_failed": "更新设备登录请求失败",
"txt_auth_request_approved": "已批准设备登录",
"txt_auth_request_denied": "已拒绝设备登录",
"txt_auth_request_missing_public_key": "设备登录请求缺少公钥",
"txt_ip_address": "IP 地址"
};
export default zhCN;
+16 -1
View File
@@ -1172,7 +1172,22 @@ const zhTW: Record<string, string> = {
"txt_target": "目標",
"txt_time": "時間",
"txt_time_range": "時間範圍",
"txt_remove_domain": "移除域名"
"txt_remove_domain": "移除域名",
"txt_approve_device_login": "批准裝置登入",
"txt_auth_request_approve_message": "解鎖您裝置上的 Bitwarden,或透過網頁 App 批准。批准前,請確保指紋短語與下面的相符。",
"txt_fingerprint_phrase": "指紋短語",
"txt_ip_address": "IP 位址",
"txt_approve": "批准",
"txt_approving": "正在批准...",
"txt_deny": "拒絕",
"txt_later": "稍後",
"txt_pending_device_logins": "待處理裝置登入",
"txt_no_pending_device_logins": "沒有待處理裝置登入",
"txt_auth_requests_load_failed": "載入裝置登入請求失敗",
"txt_auth_request_update_failed": "更新裝置登入請求失敗",
"txt_auth_request_approved": "已批准裝置登入",
"txt_auth_request_denied": "已拒絕裝置登入",
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
};
export default zhTW;
+17
View File
@@ -338,6 +338,23 @@ export interface AccountPasskeyCredential {
revisionDate?: string;
}
export interface AuthRequest {
id: string;
publicKey: string;
requestDeviceType?: string | null;
requestDeviceTypeValue?: number | null;
requestDeviceIdentifier: string;
requestIpAddress?: string | null;
requestCountryName?: string | null;
key?: string | null;
creationDate: string;
requestApproved?: boolean | null;
responseDate?: string | null;
deviceId?: string | null;
requestDeviceId?: string | null;
fingerprintPhrase?: string;
}
export interface AccountPasskeyAssertionOptionsResponse {
options: PublicKeyCredentialRequestOptions;
token: string;