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
|
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
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 (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
|
|||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||||
import { createRefreshToken } from '../utils/jwt';
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
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.');
|
return twoFactorRequiredResponse('Two factor required.');
|
||||||
}
|
}
|
||||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||||
if (!totpOk) {
|
if (matchedCounter == null) {
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
|
}
|
||||||
|
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
|
||||||
|
if (!consumed) {
|
||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 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 (' +
|
'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, ' +
|
'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, ' +
|
'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,
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||||
} from './storage-attachment-token-repo';
|
} from './storage-attachment-token-repo';
|
||||||
|
import {
|
||||||
|
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
|
||||||
|
} from './storage-totp-replay-repo';
|
||||||
import {
|
import {
|
||||||
getRevisionDate as getStoredRevisionDate,
|
getRevisionDate as getStoredRevisionDate,
|
||||||
updateRevisionDate as updateStoredRevisionDate,
|
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
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// differs from config.schema.version.
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-invite-used-by';
|
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
|
||||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -164,10 +167,13 @@ export class StorageService {
|
|||||||
private static schemaVerified = false;
|
private static schemaVerified = false;
|
||||||
private static lastRefreshTokenCleanupAt = 0;
|
private static lastRefreshTokenCleanupAt = 0;
|
||||||
private static lastAttachmentTokenCleanupAt = 0;
|
private static lastAttachmentTokenCleanupAt = 0;
|
||||||
|
private static lastTotpReplayCleanupAt = 0;
|
||||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||||
|
|
||||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
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 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;
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||||
|
|
||||||
constructor(private db: D1Database) {}
|
constructor(private db: D1Database) {}
|
||||||
@@ -833,6 +839,24 @@ export class StorageService {
|
|||||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
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 ---
|
// --- Revision dates ---
|
||||||
|
|
||||||
async getRevisionDate(userId: string): Promise<string> {
|
async getRevisionDate(userId: string): Promise<string> {
|
||||||
|
|||||||
+16
-7
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
|
|||||||
return token.replace(/\s+/g, '');
|
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);
|
const token = normalizeToken(tokenRaw);
|
||||||
if (!/^\d{6}$/.test(token)) return false;
|
if (!/^\d{6}$/.test(token)) return null;
|
||||||
|
|
||||||
const secret = base32Decode(secretRaw);
|
const secret = base32Decode(secretRaw);
|
||||||
if (!secret) return false;
|
if (!secret) return null;
|
||||||
|
|
||||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
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++) {
|
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.
|
// Constant-time comparison: always check all windows, never short-circuit.
|
||||||
const a = new TextEncoder().encode(expected);
|
const a = new TextEncoder().encode(expected);
|
||||||
const b = new TextEncoder().encode(token);
|
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++) {
|
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||||
diff |= a[i] ^ b[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 {
|
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user