fix: improve authentication reliability and account compatibility

Resolve bugs in internal authentication handling and improve consistency across supported account flows.

Maintain compatibility with existing accounts while preserving user data and established client behavior.

Validation: TypeScript checks, production build, and Worker packaging checks passed.
This commit is contained in:
shuaiplus committed 2026-10-04 15:57:29 +08:00
1 parent 588dce6a0a
commit 5f71cb388c
6 files changed
+144 -71

No files matched your search

+2 -2
View File
@@ -316,7 +316,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
const now = new Date().toISOString(); const now = new Date().toISOString();
const auth = new AuthService(env); const auth = new AuthService(env);
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email); const serverHash = await auth.hashPasswordServer(masterPasswordHash);
const user: User = { const user: User = {
id: generateUUID(), id: generateUUID(),
@@ -764,7 +764,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400); return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
} }
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash);
user.key = nextKey; user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey; if (nextPublicKey) user.publicKey = nextPublicKey;
+18 -28
View File
@@ -391,35 +391,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const user = await storage.getUser(email); const user = await storage.getUser(email);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
let validatedAuthRequestId: string | null = null; let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null; let authRequestLoginKey: string | null = null;
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id); // Auth-request grants carry an access code, not a password hash. Pay the
// same KDF cost and perform the lookup even when the account is missing.
await auth.performDummyPasswordWork(passwordHash);
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user?.id ?? '');
valid = !!( valid = !!(
user &&
user.status === 'active' &&
authRequest && authRequest &&
authRequest.userId === user.id && authRequest.userId === user.id &&
authRequest.type === 0 && authRequest.type === 0 &&
@@ -435,16 +418,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequestLoginKey = authRequest!.key; authRequestLoginKey = authRequest!.key;
} }
} else { } else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); const passwordValid = await auth.verifyPassword(passwordHash, user?.masterPasswordHash ?? null, user?.email ?? email);
valid = !!user && user.status === 'active' && passwordValid;
} }
if (!valid) { if (!valid || !user) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user?.id,
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password', action: user && user.status !== 'active'
? 'auth.login.failed.user_inactive'
: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth', category: 'auth',
level: 'warn', level: 'warn',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user?.id,
metadata: { metadata: {
grantType, grantType,
deviceIdentifier: deviceInfo.deviceIdentifier, deviceIdentifier: deviceInfo.deviceIdentifier,
@@ -577,6 +563,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
} }
if (!normalizedAuthRequestId) {
await auth.upgradePasswordVerifier(user, passwordHash);
}
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
+28 -41
View File
@@ -2,12 +2,13 @@ import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage'; import { StorageService } from './storage';
import {
createPasswordVerifier,
needsPasswordVerifierUpgrade,
performDummyPasswordWork,
verifyPasswordVerifier,
} from './password-verifier';
// Server-side iterations for second-layer hashing.
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000; const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry { interface CachedUserEntry {
@@ -134,47 +135,33 @@ export class AuthService {
return device; return device;
} }
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). async hashPasswordServer(clientHash: string): Promise<string> {
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). return createPasswordVerifier(clientHash);
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(clientHash),
'PBKDF2',
false,
['deriveBits']
);
const salt = new TextEncoder().encode(email.toLowerCase().trim());
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
keyMaterial,
256
);
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return SERVER_HASH_PREFIX + btoa(binary);
} }
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash. async verifyPassword(inputHash: string, storedHash: string | null, email: string): Promise<boolean> {
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> { return verifyPasswordVerifier(inputHash, storedHash, email);
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
return this.constantTimeEquals(inputHash, storedHash);
}
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
} }
private constantTimeEquals(a: string, b: string): boolean { async performDummyPasswordWork(inputHash: string): Promise<void> {
const encA = new TextEncoder().encode(a); await performDummyPasswordWork(inputHash);
const encB = new TextEncoder().encode(b); }
if (encA.length !== encB.length) return false;
let diff = 0; // Call only after a password login and all required second factors succeed.
for (let i = 0; i < encA.length; i++) { // Auth-request access codes are not password hashes and must never migrate rows.
diff |= encA[i] ^ encB[i]; async upgradePasswordVerifier(user: User, inputHash: string): Promise<void> {
if (!needsPasswordVerifierUpgrade(user.masterPasswordHash)) return;
try {
const upgraded = await this.hashPasswordServer(inputHash);
if (await this.storage.upgradePasswordVerifier(user.id, user.masterPasswordHash, user.securityStamp, upgraded)) {
user.masterPasswordHash = upgraded;
AuthService.invalidateUserCache(user.id);
}
} catch {
// Opportunistic migration must not prevent an existing user from logging in.
// Retry at the next fully authenticated password login; never log credentials.
console.warn('Password verifier upgrade deferred');
} }
return diff === 0;
} }
// Generate access token // Generate access token
+76
View File
@@ -0,0 +1,76 @@
import { constantTimeEquals } from '../utils/api-key';
// This is the server's second layer; the client performs its own KDF first.
const ITERATIONS = 100_000;
const LEGACY_PREFIX = '$s$';
const PREFIX = '$s2$';
const SALT_BYTES = 32;
const DUMMY_SALT = new Uint8Array(SALT_BYTES);
const BASE64_32_BYTES = /^[A-Za-z0-9+/]{43}=$/;
function toBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function decode32Bytes(value: string): Uint8Array | null {
if (!BASE64_32_BYTES.test(value)) return null;
const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
return bytes.length === SALT_BYTES && toBase64(bytes) === value ? bytes : null;
}
function parseVerifier(stored: string): { salt: Uint8Array; digest: string } | null {
const parts = stored.split('$');
// Accept only the work factor this version writes, keeping verification work
// equivalent and preventing corrupt/imported rows from requesting arbitrary work.
if (parts.length !== 5 || parts[0] !== '' || parts[1] !== 's2' || parts[2] !== String(ITERATIONS)) return null;
const salt = decode32Bytes(parts[3]);
if (!salt || !decode32Bytes(parts[4])) return null;
return { salt, digest: parts[4] };
}
async function deriveDigest(clientHash: string, salt: Uint8Array): Promise<string> {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(clientHash), 'PBKDF2', false, ['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, key, 256
);
return toBase64(new Uint8Array(bits));
}
export async function createPasswordVerifier(clientHash: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const digest = await deriveDigest(clientHash, salt);
// Self-contained version, work factor, salt and digest; no schema change.
return `${PREFIX}${ITERATIONS}$${toBase64(salt)}$${digest}`;
}
export async function performDummyPasswordWork(clientHash: string): Promise<void> {
// The result is discarded and can never authenticate anyone. The salt need
// not be random here; no password verifier is being stored.
await deriveDigest(clientHash, DUMMY_SALT);
}
export async function verifyPasswordVerifier(
clientHash: string,
stored: string | null,
email: string
): Promise<boolean> {
const verifier = stored?.startsWith(PREFIX) ? parseVerifier(stored) : null;
if (verifier) {
return constantTimeEquals(await deriveDigest(clientHash, verifier.salt), verifier.digest);
}
if (stored?.startsWith(LEGACY_PREFIX) && decode32Bytes(stored.slice(LEGACY_PREFIX.length))) {
const digest = await deriveDigest(clientHash, new TextEncoder().encode(email.toLowerCase().trim()));
return constantTimeEquals(`${LEGACY_PREFIX}${digest}`, stored);
}
// Missing users, raw legacy verifiers and malformed/unknown formats all pay
// the same PBKDF2 cost. Reserved formats must never fall back to raw equality.
await performDummyPasswordWork(clientHash);
return !!stored && !stored.startsWith('$') && constantTimeEquals(clientHash, stored);
}
export function needsPasswordVerifierUpgrade(stored: string): boolean {
return stored.startsWith(LEGACY_PREFIX) || !stored.startsWith('$');
}
+15
View File
@@ -113,6 +113,21 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
await saveUser(db, safeBind, user); await saveUser(db, safeBind, user);
} }
export async function upgradePasswordVerifier(
db: D1Database,
userId: string,
expectedVerifier: string,
securityStamp: string,
newVerifier: string
): Promise<boolean> {
// Update only the verifier, preserving vault keys, timestamps and sessions.
// A concurrent password change, disable or delete must not be overwritten.
const result = await db.prepare(
"UPDATE users SET master_password_hash = ? WHERE id = ? AND master_password_hash = ? AND security_stamp = ? AND status = 'active'"
).bind(newVerifier, userId, expectedVerifier, securityStamp).run();
return (result.meta.changes ?? 0) > 0;
}
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
+5
View File
@@ -17,6 +17,7 @@ import {
getUserById as findStoredUserById, getUserById as findStoredUserById,
getUserCount as countStoredUsers, getUserCount as countStoredUsers,
saveUser as saveStoredUser, saveUser as saveStoredUser,
upgradePasswordVerifier as upgradeStoredPasswordVerifier,
} from './storage-user-repo'; } from './storage-user-repo';
import { import {
type AuditLogListOptions, type AuditLogListOptions,
@@ -313,6 +314,10 @@ export class StorageService {
await createStoredUser(this.db, this.safeBind.bind(this), user); await createStoredUser(this.db, this.safeBind.bind(this), user);
} }
async upgradePasswordVerifier(userId: string, expectedVerifier: string, securityStamp: string, newVerifier: string): Promise<boolean> {
return upgradeStoredPasswordVerifier(this.db, userId, expectedVerifier, securityStamp, newVerifier);
}
async createFirstUser(user: User): Promise<boolean> { async createFirstUser(user: User): Promise<boolean> {
return createFirstStoredUser(this.db, this.safeBind.bind(this), user); return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
} }