diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 11451a0..e1b4fb2 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -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{ +export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise { 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 { +async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise { 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]; } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index ca8c49b..e8e1bf1 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -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 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 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 setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)} > -
+
+
{t('txt_warning')}
+
{t('txt_api_key_warning_body')}
+
+ +
+
+ + {t('txt_oauth_client_credentials')} +
{([ [t('txt_client_id'), `user.${props.profile.id}`], [t('txt_client_secret'), apiKey], [t('txt_scope'), 'api'], + [t('txt_grant_type'), 'client_credentials'], ] as [string, string][]).map(([label, value]) => ( ))}
diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index b6a0ad7..2d8ebce 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -596,7 +596,7 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom } export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { - const resp = await authedFetch('/api/accounts/api_key', { + const resp = await authedFetch('/api/accounts/api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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 { - const resp = await authedFetch('/api/accounts/rotate_api_key', { + const resp = await authedFetch('/api/accounts/rotate-api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index f237e1c..439a25f 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -609,9 +609,13 @@ const messages: Record> = { txt_api_key_rotated: "API key rotated", 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_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_secret: "client_secret", txt_scope: "scope", + txt_grant_type: "grant_type", txt_refresh: "Refresh", txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_regenerate: "Regenerate", @@ -1382,9 +1386,13 @@ const zhCNOverrides: Record = { txt_api_key_rotated: 'API 密钥已轮换', txt_rotate_api_key_confirm: '轮换 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_secret: 'client_secret', txt_scope: 'scope', + txt_grant_type: 'grant_type', txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_remove_device: '移除设备',