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

+28 -41
View File
@@ -2,12 +2,13 @@ import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
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;
interface CachedUserEntry {
@@ -134,47 +135,33 @@ export class AuthService {
return device;
}
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// 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);
async hashPasswordServer(clientHash: string): Promise<string> {
return createPasswordVerifier(clientHash);
}
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
return this.constantTimeEquals(inputHash, storedHash);
}
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
async verifyPassword(inputHash: string, storedHash: string | null, email: string): Promise<boolean> {
return verifyPasswordVerifier(inputHash, storedHash, email);
}
private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
async performDummyPasswordWork(inputHash: string): Promise<void> {
await performDummyPasswordWork(inputHash);
}
// Call only after a password login and all required second factors succeed.
// Auth-request access codes are not password hashes and must never migrate rows.
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