diff --git a/src/config/limits.ts b/src/config/limits.ts index 8eb4a4c..589d4ce 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -24,6 +24,9 @@ // Default PBKDF2 iterations for account creation/prelogin fallback. // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 defaultKdfIterations: 600000, + // clientSecret length + // clientSecret 长度 + clientSecretLength: 30, }, rateLimit: { // Max failed login attempts before temporary lock. diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 9b4204b..11451a0 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise{ + return apiKey(request, env, userId, false); +} + +// POST /api/accounts/rotate-api-key +export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise { + return apiKey(request, env, userId, true); +} + +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); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + body = await request.json(); + } + } catch { + return errorResponse('Invalid JSON', 400); + } + + const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim(); + 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); + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + } + + return jsonResponse({ + apiKey: user.apiKey, + 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 array = new Uint8Array(length); + crypto.getRandomValues(array); + + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[array[i] % chars.length]; + } + return result; +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index ceb7be1..ca8c49b 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -361,6 +361,94 @@ export async function handleToken(request: Request, env: Env): Promise ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; + } else if (grantType === 'client_credentials') { + // Login with client credentials + const clientId = body.client_id; + const clientSecret = body.client_secret; + const scope = body.scope; + const deviceInfo = readAuthRequestDeviceInfo(body, request); + + const loginIdentifier = `${clientIdentifier}:${clientId}`; + const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); + if (!parmValid) { + return identityErrorResponse('Parameter error', 'invalid_request', 400); + } + + // Check login lockout before user lookup to reduce user-enumeration signal + const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); + if (!loginCheck.allowed) { + return identityErrorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + + const uid = clientId.slice(5); + const user = await storage.getUserById(uid); + if (!user) { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); + } + if (user.status !== 'active') { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + } + + + // Persist device only after successful password + (optional) 2FA verification. + const deviceSession = + deviceInfo.deviceIdentifier + ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } + : null; + if (deviceSession) { + await storage.upsertDevice( + user.id, + deviceSession.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + deviceSession.sessionStamp + ); + } + + // Successful login - clear failed attempts + await rateLimit.clearLoginAttempts(loginIdentifier); + + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: LIMITS.auth.accessTokenTtlSeconds, + token_type: 'Bearer', + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken } ), + Key: user.key, + PrivateKey: user.privateKey, + AccountKeys: accountKeys, + accountKeys: accountKeys, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, + }; + + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; + } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (!sendAccessLimit.allowed) { @@ -553,3 +641,16 @@ export async function handleRevocation(request: Request, env: Env): Promise