mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-23 22:28:22 -05:00
Add login with mobile QR code client side logic (#1347)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -48,6 +48,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
'/',
|
||||
'/reinitialize',
|
||||
'/login',
|
||||
'/mobile-unlock',
|
||||
'/unlock',
|
||||
'/unlock-success',
|
||||
'/auth-settings',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user