fix: harden API key authentication

This commit is contained in:
shuaiplus
2026-04-23 23:17:05 +08:00
parent 1147c1e013
commit fe8d9e0b7d
7 changed files with 86 additions and 21 deletions
+13 -9
View File
@@ -754,7 +754,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
}
// POST /api/accounts/api-key
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response>{
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, false);
}
@@ -763,7 +763,7 @@ export async function handleRotateApiKey(request: Request, env: Env, userId: str
return apiKey(request, env, userId, true);
}
async function apiKey(request: Request, env: Env, userId: string,rotate: boolean): Promise<Response> {
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
@@ -786,28 +786,32 @@ async function apiKey(request: Request, env: Env, userId: string,rotate: boolean
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
return jsonResponse({
apiKey: user.apiKey,
revisionDate: user.updatedAt,
revisionDate: user.updatedAt,
object: 'apiKey',
});
});
}
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = "";
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
+20 -4
View File
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null {
return null;
}
function 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];
}
return diff === 0;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:';
const parts = [
@@ -395,8 +407,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful password + (optional) 2FA verification.
// Persist device only after successful client credential verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
@@ -423,7 +439,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken } ),
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
@@ -643,10 +659,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
}
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
if (scope !== "api") {
if (scope !== 'api') {
return false;
}
if (!clientId.startsWith("user.")) {
if (!clientId.startsWith('user.')) {
return false;
}
if (!clientSecret) {
+2 -2
View File
@@ -121,11 +121,11 @@ export async function handleAuthenticatedRoute(
return handleSetVerifyDevices(request, env, userId);
}
if (path === '/api/accounts/api_key' && method === 'POST') {
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
return handleGetApiKey(request, env, userId);
}
if (path === '/api/accounts/rotate_api_key' && method === 'POST') {
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
return handleRotateApiKey(request, env, userId);
}
+1 -1
View File
@@ -105,7 +105,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
const email = user.email.toLowerCase();
const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(