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
+27 -2
View File
@@ -19,6 +19,7 @@ import {
assertAccountPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -237,6 +238,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Login with password
const email = body.username?.toLowerCase();
const passwordHash = body.password;
const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
@@ -281,11 +283,31 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
let validatedAuthRequestId: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
authRequest.type === 0 &&
authRequest.approved === true &&
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
}
} else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
}
if (!valid) {
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_password',
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
@@ -384,6 +406,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
if (validatedAuthRequestId) {
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
}
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);