mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows. - Introduced device management endpoints to handle known devices and their registration. - Enhanced database schema to include devices and trusted two-factor tokens. - Updated response handling to include two-factor token in successful login responses. - Modified registration and login pages to guide users through enabling TOTP. - Improved device identification and management utilities for better user experience.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||
const DEFAULT_DEVICE_TYPE = 14;
|
||||
|
||||
function decodeBase64UrlUtf8(value: string): string | null {
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value: string | undefined | null): string {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function parseDeviceType(value: string | number | undefined | null): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||
return DEFAULT_DEVICE_TYPE;
|
||||
}
|
||||
|
||||
export interface AuthRequestDeviceInfo {
|
||||
deviceIdentifier: string | null;
|
||||
deviceName: string;
|
||||
deviceType: number;
|
||||
}
|
||||
|
||||
export function readAuthRequestDeviceInfo(
|
||||
body: Record<string, string | undefined>,
|
||||
request: Request
|
||||
): AuthRequestDeviceInfo {
|
||||
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||
const bodyName = body.deviceName || body.device_name;
|
||||
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||
const bodyType = body.deviceType || body.device_type;
|
||||
const headerType = request.headers.get('Device-Type') || undefined;
|
||||
|
||||
return {
|
||||
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||
deviceType: parseDeviceType(bodyType || headerType),
|
||||
};
|
||||
}
|
||||
|
||||
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||
return { email, deviceIdentifier };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
||||
|
||||
function isTrustedClientOrigin(origin: string): boolean {
|
||||
// Official browser extension / desktop-webview common origins.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const TOTP_STEP_SECONDS = 30;
|
||||
const TOTP_DIGITS = 6;
|
||||
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||
|
||||
function normalizeBase32(input: string): string {
|
||||
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Uint8Array | null {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const normalized = normalizeBase32(input);
|
||||
if (!normalized) return null;
|
||||
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output: number[] = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const idx = alphabet.indexOf(char);
|
||||
if (idx === -1) return null;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return output.length > 0 ? new Uint8Array(output) : null;
|
||||
}
|
||||
|
||||
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||
const counterBytes = new Uint8Array(8);
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = c & 0xff;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
secret,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||
const offset = signature[signature.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((signature[offset] & 0x7f) << 24) |
|
||||
((signature[offset + 1] & 0xff) << 16) |
|
||||
((signature[offset + 2] & 0xff) << 8) |
|
||||
(signature[offset + 3] & 0xff);
|
||||
|
||||
const otp = binary % (10 ** TOTP_DIGITS);
|
||||
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||
}
|
||||
|
||||
function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
if (expected === token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user