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
+8 -4
View File
@@ -754,7 +754,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
} }
// POST /api/accounts/api-key // 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); 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); 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 storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
@@ -790,6 +790,10 @@ async function apiKey(request: Request, env: Env, userId: string,rotate: boolean
if (rotate || user.apiKey === null) { if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings // Upstream apikeys are 30-character random alphanumeric strings
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
} }
@@ -803,11 +807,11 @@ async function apiKey(request: Request, env: Env, userId: string,rotate: boolean
// Generate a random alphanumeric string of the given length using crypto.getRandomValues. // Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string { function randomStringAlphanum(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length); const array = new Uint8Array(length);
crypto.getRandomValues(array); crypto.getRandomValues(array);
let result = ""; let result = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length]; result += chars[array[i] % chars.length];
} }
+20 -4
View File
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null {
return 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 { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ const parts = [
@@ -395,8 +407,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); 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 = const deviceSession =
deviceInfo.deviceIdentifier deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
@@ -423,7 +439,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken } ), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, 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 { export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
if (scope !== "api") { if (scope !== 'api') {
return false; return false;
} }
if (!clientId.startsWith("user.")) { if (!clientId.startsWith('user.')) {
return false; return false;
} }
if (!clientSecret) { if (!clientSecret) {
+2 -2
View File
@@ -121,11 +121,11 @@ export async function handleAuthenticatedRoute(
return handleSetVerifyDevices(request, env, userId); 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); 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); 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 email = user.email.toLowerCase();
const stmt = db.prepare( 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) ' + '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)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
+39 -2
View File
@@ -294,21 +294,58 @@ export default function SettingsPage(props: SettingsPageProps) {
<ConfirmDialog <ConfirmDialog
open={apiKeyDialogOpen} open={apiKeyDialogOpen}
title={t('txt_api_key')} title={t('txt_api_key')}
message="" message={t('txt_api_key_dialog_intro')}
hideCancel hideCancel
confirmText={t('txt_close')} confirmText={t('txt_close')}
onConfirm={() => setApiKeyDialogOpen(false)} onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)}
> >
<div className="stack" style={{ gap: 8, marginTop: 4 }}> <div
style={{
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
</div>
<div
style={{
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span>
</div>
{([ {([
[t('txt_client_id'), `user.${props.profile.id}`], [t('txt_client_id'), `user.${props.profile.id}`],
[t('txt_client_secret'), apiKey], [t('txt_client_secret'), apiKey],
[t('txt_scope'), 'api'], [t('txt_scope'), 'api'],
[t('txt_grant_type'), 'client_credentials'],
] as [string, string][]).map(([label, value]) => ( ] as [string, string][]).map(([label, value]) => (
<label key={label} className="field"> <label key={label} className="field">
<span>{label}</span> <span>{label}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} /> <input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button
type="button"
className="btn btn-secondary small"
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy')}
</button>
</div>
</label> </label>
))} ))}
</div> </div>
+2 -2
View File
@@ -596,7 +596,7 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom
} }
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> { export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/api_key', { const resp = await authedFetch('/api/accounts/api-key', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }), body: JSON.stringify({ masterPasswordHash }),
@@ -610,7 +610,7 @@ export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: st
} }
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> { export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/rotate_api_key', { const resp = await authedFetch('/api/accounts/rotate-api-key', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }), body: JSON.stringify({ masterPasswordHash }),
+8
View File
@@ -609,9 +609,13 @@ const messages: Record<Locale, Record<string, string>> = {
txt_api_key_rotated: "API key rotated", txt_api_key_rotated: "API key rotated",
txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.", txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.",
txt_api_key_is_empty: "API key is empty", txt_api_key_is_empty: "API key is empty",
txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.",
txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.",
txt_oauth_client_credentials: "OAuth 2.0 Client Credentials",
txt_client_id: "client_id", txt_client_id: "client_id",
txt_client_secret: "client_secret", txt_client_secret: "client_secret",
txt_scope: "scope", txt_scope: "scope",
txt_grant_type: "grant_type",
txt_refresh: "Refresh", txt_refresh: "Refresh",
txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_refresh_in_seconds_s: "Refresh in {seconds}s",
txt_regenerate: "Regenerate", txt_regenerate: "Regenerate",
@@ -1382,9 +1386,13 @@ const zhCNOverrides: Record<string, string> = {
txt_api_key_rotated: 'API 密钥已轮换', txt_api_key_rotated: 'API 密钥已轮换',
txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。', txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。',
txt_api_key_is_empty: 'API 密钥为空', txt_api_key_is_empty: 'API 密钥为空',
txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。',
txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。',
txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据',
txt_client_id: 'client_id', txt_client_id: 'client_id',
txt_client_secret: 'client_secret', txt_client_secret: 'client_secret',
txt_scope: 'scope', txt_scope: 'scope',
txt_grant_type: 'grant_type',
txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_registration_succeeded_please_sign_in: '注册成功,请登录',
txt_remove_device: '移除设备', txt_remove_device: '移除设备',