mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-24 06:20:14 +00:00
fix:Harden authentication and sensitive file handling
This commit is contained in:
@@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS totp_login_replays (
|
||||
user_id TEXT NOT NULL,
|
||||
time_counter INTEGER NOT NULL,
|
||||
consumed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, time_counter),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
|
||||
ON totp_login_replays(consumed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
@@ -409,8 +409,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (!totpOk) {
|
||||
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (matchedCounter == null) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
|
||||
if (!consumed) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -127,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
|
||||
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, time_counter), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function consumeTotpLoginCounter(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
userId: string,
|
||||
timeCounter: number,
|
||||
consumedAtMs: number,
|
||||
markerTtlMs: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
|
||||
.bind(consumedAtMs - markerTtlMs)
|
||||
.run();
|
||||
cleanedUpAt = consumedAtMs;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, time_counter) DO NOTHING'
|
||||
)
|
||||
.bind(userId, timeCounter, consumedAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
+26
-2
@@ -123,6 +123,9 @@ import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||
} from './storage-attachment-token-repo';
|
||||
import {
|
||||
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
|
||||
} from './storage-totp-replay-repo';
|
||||
import {
|
||||
getRevisionDate as getStoredRevisionDate,
|
||||
updateRevisionDate as updateStoredRevisionDate,
|
||||
@@ -150,8 +153,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-invite-used-by';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -164,10 +167,13 @@ export class StorageService {
|
||||
private static schemaVerified = false;
|
||||
private static lastRefreshTokenCleanupAt = 0;
|
||||
private static lastAttachmentTokenCleanupAt = 0;
|
||||
private static lastTotpReplayCleanupAt = 0;
|
||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||
|
||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
||||
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
|
||||
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
@@ -833,6 +839,24 @@ export class StorageService {
|
||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
||||
}
|
||||
|
||||
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
|
||||
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
|
||||
const result = await consumeStoredTotpLoginCounter(
|
||||
this.db,
|
||||
this.shouldRunPeriodicCleanup.bind(this),
|
||||
StorageService.lastTotpReplayCleanupAt,
|
||||
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
|
||||
userId,
|
||||
timeCounter,
|
||||
consumedAtMs,
|
||||
StorageService.TOTP_REPLAY_MARKER_TTL_MS
|
||||
);
|
||||
if (result.cleanedUpAt !== null) {
|
||||
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
|
||||
}
|
||||
return result.consumed;
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
+16
-7
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
export async function findMatchingTotpCounter(
|
||||
secretRaw: string,
|
||||
tokenRaw: string,
|
||||
nowMs: number = Date.now()
|
||||
): Promise<number | null> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
if (!/^\d{6}$/.test(token)) return null;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
if (!secret) return null;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
let matched = false;
|
||||
let matchedCounter: number | null = null;
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
const candidateCounter = currentCounter + delta;
|
||||
const expected = await hotp(secret, candidateCounter);
|
||||
// Constant-time comparison: always check all windows, never short-circuit.
|
||||
const a = new TextEncoder().encode(expected);
|
||||
const b = new TextEncoder().encode(token);
|
||||
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
|
||||
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
if (diff === 0) matched = true;
|
||||
if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
|
||||
}
|
||||
return matched;
|
||||
return matchedCounter;
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
|
||||
Reference in New Issue
Block a user