Add login with mobile QR code client side logic (#1347)

This commit is contained in:
Leendert de Borst
2025-11-16 20:23:50 +01:00
parent 7b78552651
commit f50fe913fb
7 changed files with 410 additions and 1 deletions

View File

@@ -32,6 +32,7 @@
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
@@ -47,6 +48,7 @@
"@types/chrome": "^0.0.280",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.10",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",

View File

@@ -13,6 +13,7 @@ import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
import AuthSettings from '@/entrypoints/popup/pages/auth/AuthSettings';
import Login from '@/entrypoints/popup/pages/auth/Login';
import MobileUnlock from '@/entrypoints/popup/pages/auth/MobileUnlock';
import Unlock from '@/entrypoints/popup/pages/auth/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess';
import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade';
@@ -178,6 +179,7 @@ const App: React.FC = () => {
{ path: '/', element: <Index />, showBackButton: false },
{ path: '/reinitialize', element: <Reinitialize />, showBackButton: false },
{ path: '/login', element: <Login />, showBackButton: false, layout: LayoutType.AUTH },
{ path: '/mobile-unlock', element: <MobileUnlock />, showBackButton: false, layout: LayoutType.AUTH },
{ path: '/unlock', element: <Unlock />, showBackButton: false, layout: LayoutType.AUTH },
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },

View File

@@ -48,6 +48,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
'/',
'/reinitialize',
'/login',
'/mobile-unlock',
'/unlock',
'/unlock-success',
'/auth-settings',

View File

@@ -402,10 +402,13 @@ const Login: React.FC = () => {
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
</label>
</div>
<div className="flex w-full">
<div className="flex flex-col w-full space-y-2">
<Button type="submit">
{t('auth.loginButton')}
</Button>
<Button type="button" onClick={() => navigate('/mobile-unlock')} variant="secondary">
{t('auth.unlockWithMobile')}
</Button>
</div>
</form>
<div className="text-center text-gray-600 dark:text-gray-400">

View File

@@ -0,0 +1,234 @@
import QRCode from 'qrcode';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { MobileUnlockUtility } from '@/entrypoints/popup/utils/MobileUnlockUtility';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
/**
* Mobile unlock page - scan QR code with mobile device to unlock.
*/
const MobileUnlock: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const webApi = useWebApi();
const { initializeDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams } = useDb();
const { setAuthTokens } = useAuth();
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(120); // 2 minutes in seconds
const mobileUnlockRef = useRef<MobileUnlockUtility | null>(null);
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Countdown timer effect
useEffect(() => {
if (qrCodeUrl && timeRemaining > 0) {
countdownIntervalRef.current = setInterval(() => {
setTimeRemaining(prev => {
if (prev <= 1) {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
return 0;
}
return prev - 1;
});
}, 1000);
return (): void => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
}
}, [qrCodeUrl, timeRemaining]);
useEffect(() => {
/**
* Initialize mobile unlock on component mount.
*/
const initiateMobileUnlock = async () : Promise<void> => {
try {
showLoading();
setError(null);
// Initialize mobile unlock utility
if (!mobileUnlockRef.current) {
mobileUnlockRef.current = new MobileUnlockUtility(webApi);
}
// Initiate mobile unlock and get QR code data
const requestId = await mobileUnlockRef.current.initiate();
// Generate QR code with AliasVault prefix for mobile unlock
const qrData = `aliasvault://mobile-unlock/${requestId}`;
const qrDataUrl = await QRCode.toDataURL(qrData, {
width: 256,
margin: 2,
});
setQrCodeUrl(qrDataUrl);
hideLoading();
// Start polling for response
await mobileUnlockRef.current.startPolling(
async (username, token, refreshToken, decryptionKey, salt, encryptionType, encryptionSettings) => {
showLoading();
try {
// Handle successful authentication
await handleSuccessfulAuth(
username,
token,
refreshToken,
decryptionKey,
{
salt,
encryptionType,
encryptionSettings,
}
);
} catch (err) {
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
hideLoading();
}
},
(errorMessage) => {
setError(errorMessage);
hideLoading();
}
);
} catch (err) {
hideLoading();
setError(err instanceof Error ? err.message : t('auth.errors.serverError'));
}
};
initiateMobileUnlock();
// Cleanup on unmount
return (): void => {
if (mobileUnlockRef.current) {
mobileUnlockRef.current.cleanup();
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
}, []);
/**
* Handle successful authentication.
*/
const handleSuccessfulAuth = async (
username: string,
token: string,
refreshToken: string,
decryptionKey: string,
vaultMetadata: { salt: string; encryptionType: string; encryptionSettings: string }
) : Promise<void> => {
// Fetch vault from server with the new auth token
const vaultResponse = await webApi.authFetch<VaultResponse>('Vault', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
// Store auth tokens and username
await setAuthTokens(username, token, refreshToken);
// Store the encryption key and derivation params
await storeEncryptionKey(decryptionKey);
await storeEncryptionKeyDerivationParams({
salt: vaultMetadata.salt,
encryptionType: vaultMetadata.encryptionType,
encryptionSettings: vaultMetadata.encryptionSettings,
});
// Initialize the database with the vault data
const sqliteClient = await initializeDatabase(vaultResponse, decryptionKey);
// Check for pending migrations
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
hideLoading();
return;
}
// Navigate to credentials page.
hideLoading();
setIsInitialLoading(false);
navigate('/credentials', { replace: true });
};
/**
* Handle back button.
*/
const handleBack = () : void => {
if (mobileUnlockRef.current) {
mobileUnlockRef.current.cleanup();
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
navigate('/login');
};
/**
* Format time remaining as MM:SS.
*/
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div>
<div className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
{error}
</div>
)}
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">{t('auth.unlockWithMobile')}</h2>
<p className="text-gray-700 dark:text-gray-200 mb-4 text-sm">
{t('auth.scanQrCode')}
</p>
{qrCodeUrl && (
<div className="flex flex-col items-center mb-6">
<img src={qrCodeUrl} alt="QR Code" className="border-4 border-gray-200 dark:border-gray-600 rounded mb-4" />
<div className="text-gray-700 text-sm dark:text-gray-300">
{formatTime(timeRemaining)}
</div>
</div>
)}
<div className="flex w-full">
<Button type="button" onClick={handleBack} variant="secondary">
{t('auth.cancel')}
</Button>
</div>
</div>
</div>
);
};
export default MobileUnlock;

View File

@@ -0,0 +1,165 @@
import { Buffer } from 'buffer';
import type { MobileUnlockInitiateResponse, MobileUnlockPollResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import type { WebApiService } from '@/utils/WebApiService';
/**
* Utility class for mobile unlock operations
*/
export class MobileUnlockUtility {
private webApi: WebApiService;
private pollingInterval: NodeJS.Timeout | null = null;
private requestId: string | null = null;
private privateKey: string | null = null;
/**
* Constructor for the MobileUnlockUtility class.
*
* @param {WebApiService} webApi - The WebApiService instance.
*/
public constructor(webApi: WebApiService) {
this.webApi = webApi;
}
/**
* Initiates a mobile unlock request and returns the QR code data
*/
public async initiate(): Promise<string> {
// Generate RSA key pair
const keyPair = await EncryptionUtility.generateRsaKeyPair();
this.privateKey = keyPair.privateKey;
// Send public key to server (no auth required)
const response = await this.webApi.rawFetch('auth/mobile-unlock/initiate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientPublicKey: keyPair.publicKey,
}),
});
if (!response.ok) {
throw new Error(`Failed to initiate mobile unlock: ${response.status}`);
}
const data = await response.json() as MobileUnlockInitiateResponse;
this.requestId = data.requestId;
// Return QR code data (request ID)
return this.requestId;
}
/**
* Starts polling the server for mobile unlock response
*/
public async startPolling(
onSuccess: (username: string, token: string, refreshToken: string, decryptionKey: string, salt: string, encryptionType: string, encryptionSettings: string) => void,
onError: (error: string) => void
): Promise<void> {
if (!this.requestId || !this.privateKey) {
throw new Error('Must call initiate() before starting polling');
}
/**
* Polls the server for mobile unlock response
*/
const pollFn = async (): Promise<void> => {
try {
if (!this.requestId) {
this.stopPolling();
return;
}
const response = await this.webApi.rawFetch(
`auth/mobile-unlock/poll/${this.requestId}`,
{
method: 'GET',
}
);
if (!response.ok) {
if (response.status === 404) {
// Request expired or not found
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError('Mobile unlock request expired');
return;
}
throw new Error(`Polling failed: ${response.status}`);
}
const data = await response.json() as MobileUnlockPollResponse;
if (data.fulfilled && data.encryptedDecryptionKey && data.username && data.token && data.salt && data.encryptionType && data.encryptionSettings) {
// Stop polling
this.stopPolling();
// Decrypt the decryption key using private key
const decryptionKeyBytes = await EncryptionUtility.decryptWithPrivateKey(
data.encryptedDecryptionKey,
this.privateKey!
);
// Convert to base64 string
const decryptionKey = Buffer.from(decryptionKeyBytes).toString('base64');
// Clear sensitive data
this.privateKey = null;
this.requestId = null;
// Call success callback
onSuccess(
data.username,
data.token.token,
data.token.refreshToken,
decryptionKey,
data.salt,
data.encryptionType,
data.encryptionSettings
);
}
} catch (error) {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError(error instanceof Error ? error.message : 'Unknown error occurred');
}
};
// Poll every 3 seconds
this.pollingInterval = setInterval(pollFn, 3000);
// Stop polling after 2 minutes
setTimeout(() => {
if (this.pollingInterval) {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
onError('Mobile unlock request timed out');
}
}, 120000);
}
/**
* Stops polling the server
*/
public stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* Cleans up resources
*/
public cleanup(): void {
this.stopPolling();
this.privateKey = null;
this.requestId = null;
}
}

View File

@@ -30,6 +30,8 @@
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"unlockWithMobile": "Unlock with Mobile",
"scanQrCode": "Scan this QR code with your AliasVault mobile app to unlock your vault.",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",