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:
@@ -4,6 +4,7 @@ import { AuthService } from '../services/auth';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
if (!value) return false;
|
||||
@@ -128,7 +129,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
// - X-Device-Identifier: client device identifier
|
||||
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||
|
||||
if (!email || !deviceIdentifier) {
|
||||
return jsonResponse(false);
|
||||
}
|
||||
|
||||
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||
return jsonResponse(known);
|
||||
}
|
||||
|
||||
// GET /api/devices
|
||||
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const devices = await storage.getDevicesByUserId(userId);
|
||||
|
||||
return jsonResponse({
|
||||
data: devices.map(device => ({
|
||||
id: device.deviceIdentifier,
|
||||
name: device.name,
|
||||
identifier: device.deviceIdentifier,
|
||||
type: device.type,
|
||||
creationDate: device.createdAt,
|
||||
revisionDate: device.updatedAt,
|
||||
object: 'device',
|
||||
})),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,48 @@ import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
TwoFactorProviders: [0],
|
||||
TwoFactorProviders2: {
|
||||
'0': {
|
||||
Priority: 1,
|
||||
},
|
||||
},
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
async function recordFailedLoginAndBuildResponse(
|
||||
rateLimit: RateLimitService,
|
||||
loginIdentifier: string,
|
||||
message: string
|
||||
): Promise<Response> {
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// POST /identity/connect/token
|
||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||
@@ -30,7 +72,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
const passwordHash = body.password;
|
||||
const twoFactorToken = body.twoFactorToken;
|
||||
const twoFactorProvider = body.twoFactorProvider;
|
||||
const twoFactorRemember = body.twoFactorRemember;
|
||||
const loginIdentifier = getClientIdentifier(request);
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
// Bitwarden clients expect OAuth-style error fields.
|
||||
@@ -55,16 +101,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||
if (!valid) {
|
||||
// Record failed login attempt
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
loginIdentifier,
|
||||
'Username or password is incorrect. Try again'
|
||||
);
|
||||
}
|
||||
|
||||
if (deviceInfo.deviceIdentifier) {
|
||||
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
|
||||
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
||||
let passedByRememberToken = false;
|
||||
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) {
|
||||
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||
twoFactorToken,
|
||||
deviceInfo.deviceIdentifier
|
||||
);
|
||||
passedByRememberToken = trustedUserId === user.id;
|
||||
}
|
||||
|
||||
if (!passedByRememberToken && !twoFactorToken) {
|
||||
return twoFactorRequiredResponse();
|
||||
}
|
||||
|
||||
if (!passedByRememberToken) {
|
||||
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken);
|
||||
if (!totpOk) {
|
||||
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (failed.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (rememberRequested && deviceInfo.deviceIdentifier) {
|
||||
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||
await storage.saveTrustedTwoFactorDeviceToken(
|
||||
trustedTwoFactorTokenToReturn,
|
||||
user.id,
|
||||
deviceInfo.deviceIdentifier,
|
||||
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
@@ -78,6 +168,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
Kdf: user.kdfType,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
body: string;
|
||||
@@ -73,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
Reference in New Issue
Block a user