mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance two-factor authentication handling and improve error responses
This commit is contained in:
+56
-26
@@ -9,6 +9,8 @@ import { createRefreshToken } from '../utils/jwt';
|
|||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
@@ -16,9 +18,15 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
|
|||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
error_description: message,
|
error_description: message,
|
||||||
TwoFactorProviders: [0],
|
TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)],
|
||||||
TwoFactorProviders2: {
|
TwoFactorProviders2: {
|
||||||
'0': null,
|
[String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]: null,
|
||||||
|
},
|
||||||
|
// Required by current Android parser (nullable value is acceptable).
|
||||||
|
SsoEmail2faSessionToken: null,
|
||||||
|
// Keep payload shape close to upstream implementations.
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
},
|
},
|
||||||
ErrorModel: {
|
ErrorModel: {
|
||||||
Message: message,
|
Message: message,
|
||||||
@@ -45,6 +53,21 @@ async function recordFailedLoginAndBuildResponse(
|
|||||||
return identityErrorResponse(message, 'invalid_grant', 400);
|
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recordFailedTwoFactorAndBuildResponse(
|
||||||
|
rateLimit: RateLimitService,
|
||||||
|
loginIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
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('Two-step token is invalid. Try again.', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /identity/connect/token
|
// POST /identity/connect/token
|
||||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -106,50 +129,52 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
if (isTotpEnabled(env.TOTP_SECRET)) {
|
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
if (normalizedTwoFactorProvider !== '' && normalizedTwoFactorProvider !== '0') {
|
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||||
return identityErrorResponse('Unsupported two-factor provider', 'invalid_grant', 400);
|
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||||
|
const hasProvider = normalizedTwoFactorProvider.length > 0;
|
||||||
|
const hasToken = normalizedTwoFactorToken.length > 0;
|
||||||
|
|
||||||
|
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||||
|
// respond with a 2FA challenge payload.
|
||||||
|
if (!hasProvider || !hasToken) {
|
||||||
|
return twoFactorRequiredResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
|
||||||
|
if (!Number.isFinite(parsedProvider)) {
|
||||||
|
return twoFactorRequiredResponse();
|
||||||
|
}
|
||||||
|
|
||||||
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
|
||||||
let passedByRememberToken = false;
|
let passedByRememberToken = false;
|
||||||
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) {
|
if (parsedProvider === TWO_FACTOR_PROVIDER_REMEMBER) {
|
||||||
|
if (deviceInfo.deviceIdentifier) {
|
||||||
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||||
twoFactorToken,
|
normalizedTwoFactorToken,
|
||||||
deviceInfo.deviceIdentifier
|
deviceInfo.deviceIdentifier
|
||||||
);
|
);
|
||||||
passedByRememberToken = trustedUserId === user.id;
|
passedByRememberToken = trustedUserId === user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passedByRememberToken && !twoFactorToken) {
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
|
if (!passedByRememberToken) {
|
||||||
return twoFactorRequiredResponse();
|
return twoFactorRequiredResponse();
|
||||||
}
|
}
|
||||||
|
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
if (!passedByRememberToken) {
|
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, normalizedTwoFactorToken);
|
||||||
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken);
|
|
||||||
if (!totpOk) {
|
if (!totpOk) {
|
||||||
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, 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);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rememberRequested && deviceInfo.deviceIdentifier) {
|
// Upstream behavior: do not issue a new remember token when auth itself used remember provider.
|
||||||
|
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
|
||||||
trustedTwoFactorTokenToReturn = createRefreshToken();
|
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||||
await storage.saveTrustedTwoFactorDeviceToken(
|
await storage.saveTrustedTwoFactorDeviceToken(
|
||||||
trustedTwoFactorTokenToReturn,
|
trustedTwoFactorTokenToReturn,
|
||||||
@@ -160,6 +185,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist device only after successful password + (optional) 2FA verification.
|
||||||
|
if (deviceInfo.deviceIdentifier) {
|
||||||
|
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
||||||
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user