mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat(server): Add api key handler
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
// clientSecret length
|
||||||
|
// clientSecret 长度
|
||||||
|
clientSecretLength: 30,
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
// Max failed login attempts before temporary lock.
|
// Max failed login attempts before temporary lock.
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
verifyDevices: true,
|
verifyDevices: true,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
|
apiKey: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -751,3 +752,64 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/api-key
|
||||||
|
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response>{
|
||||||
|
return apiKey(request, env, userId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/rotate-api-key
|
||||||
|
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
return apiKey(request, env, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, string | undefined>;
|
||||||
|
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<string, string>;
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -361,6 +361,94 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: 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') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
if (!sendAccessLimit.allowed) {
|
if (!sendAccessLimit.allowed) {
|
||||||
@@ -553,3 +641,16 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
? withWebRefreshCookie(request, baseResponse, null)
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||||
|
if (scope !== "api") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientId.startsWith("user.")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetApiKey,
|
||||||
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
import {
|
import {
|
||||||
handleGetCiphers,
|
handleGetCiphers,
|
||||||
@@ -119,6 +121,14 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleSetVerifyDevices(request, env, userId);
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/api_key' && method === 'POST') {
|
||||||
|
return handleGetApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/rotate_api_key' && method === 'POST') {
|
||||||
|
return handleRotateApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
return handleSync(request, env, userId);
|
return handleSync(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user