mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
docs: update README files for clarity on deployment steps and features
This commit is contained in:
@@ -41,6 +41,9 @@
|
||||
// /api/devices/knowndevice probe budget per IP per minute.
|
||||
// /api/devices/knowndevice 每 IP 每分钟探测配额。
|
||||
knownDeviceRequestsPerMinute: 10,
|
||||
// Public Send access budget per IP per minute.
|
||||
// 公共 Send 访问接口每 IP 每分钟配额。
|
||||
publicSendRequestsPerMinute: 60,
|
||||
// Fixed window size for API rate limiting in seconds.
|
||||
// API 限流固定窗口大小(秒)。
|
||||
apiWindowSeconds: 60,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
@@ -449,6 +450,7 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
|
||||
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
|
||||
let body: Record<string, string | undefined>;
|
||||
try {
|
||||
@@ -466,20 +468,35 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
const email = String(body.email || body.username || '').trim().toLowerCase();
|
||||
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
||||
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
||||
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
|
||||
|
||||
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
||||
if (!recoverAttemptCheck.allowed) {
|
||||
return errorResponse(
|
||||
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
if (!email || !masterPasswordHash || !recoveryCode) {
|
||||
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user) return errorResponse('Invalid credentials', 400);
|
||||
if (user.status !== 'active') return errorResponse('Account is disabled', 403);
|
||||
if (!user || user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||
return errorResponse('Invalid credentials or recovery code', 400);
|
||||
}
|
||||
|
||||
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash);
|
||||
if (!validPassword) return errorResponse('Invalid credentials', 400);
|
||||
if (!validPassword) {
|
||||
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||
return errorResponse('Invalid credentials or recovery code', 400);
|
||||
}
|
||||
|
||||
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
|
||||
return errorResponse('Recovery code is incorrect. Try again.', 400);
|
||||
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||
return errorResponse('Invalid credentials or recovery code', 400);
|
||||
}
|
||||
|
||||
user.totpSecret = null;
|
||||
@@ -488,6 +505,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
const grantType = body.grant_type;
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
|
||||
if (grantType === 'password') {
|
||||
// Login with password
|
||||
@@ -113,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const twoFactorToken = body.twoFactorToken;
|
||||
const twoFactorProvider = body.twoFactorProvider;
|
||||
const twoFactorRemember = body.twoFactorRemember;
|
||||
const loginIdentifier = getClientIdentifier(request);
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
@@ -266,6 +267,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return jsonResponse(response);
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`);
|
||||
if (!sendAccessLimit.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const sendId = String(body.send_id || body.sendId || '').trim();
|
||||
if (!sendId) {
|
||||
return jsonResponse(
|
||||
|
||||
+18
-2
@@ -322,6 +322,14 @@ function hasEmailAuth(send: Send): boolean {
|
||||
return send.authType === SendAuthType.Email;
|
||||
}
|
||||
|
||||
function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
}
|
||||
|
||||
function extractBearerToken(request: Request): string | null {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) return null;
|
||||
@@ -1078,12 +1086,15 @@ export async function handleAccessSendFile(
|
||||
|
||||
// POST /api/sends/access (v2 bearer)
|
||||
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) return jwt.response;
|
||||
|
||||
const token = extractBearerToken(request);
|
||||
if (!token) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
|
||||
const claims = await verifySendAccessToken(token, env.JWT_SECRET);
|
||||
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Unauthorized', 401);
|
||||
}
|
||||
@@ -1196,6 +1207,11 @@ export async function issueSendAccessToken(
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null
|
||||
): Promise<{ token: string } | { error: Response }> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) {
|
||||
return { error: jwt.response };
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||
|
||||
@@ -1260,7 +1276,7 @@ export async function issueSendAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createSendAccessToken(send.id, env.JWT_SECRET);
|
||||
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||
return { token };
|
||||
}
|
||||
|
||||
|
||||
+28
-6
@@ -12,7 +12,6 @@ import { handleToken, handlePrelogin, handleRevocation } from './handlers/identi
|
||||
import {
|
||||
handleRegister,
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
@@ -214,6 +213,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
async function enforcePublicSendRateLimit(): Promise<Response | null> {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`);
|
||||
if (check.allowed) return null;
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
@@ -272,23 +289,31 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
// Public Send access endpoints
|
||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||
if (sendAccessMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicSendRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const accessId = sendAccessMatch[1];
|
||||
return handleAccessSend(request, env, accessId);
|
||||
}
|
||||
|
||||
const sendAccessV2Match = path === '/api/sends/access';
|
||||
if (sendAccessV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicSendRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return handleAccessSendV2(request, env);
|
||||
}
|
||||
|
||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
||||
if (sendAccessFileV2Match && method === 'POST') {
|
||||
const blocked = await enforcePublicSendRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const fileId = sendAccessFileV2Match[1];
|
||||
return handleAccessSendFileV2(request, env, fileId);
|
||||
}
|
||||
|
||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
||||
if (sendAccessFileMatch && method === 'POST') {
|
||||
const blocked = await enforcePublicSendRateLimit();
|
||||
if (blocked) return blocked;
|
||||
const idOrAccessId = sendAccessFileMatch[1];
|
||||
const fileId = sendAccessFileMatch[2];
|
||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
||||
@@ -309,8 +334,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
// Known device check (no auth required)
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const clientIp = getClientIdentifier(request);
|
||||
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientIp + ':known-device');
|
||||
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientId + ':known-device');
|
||||
if (!probeLimit.allowed) {
|
||||
// Keep compatibility simple: do not error, just answer "unknown device".
|
||||
return jsonResponse(false);
|
||||
@@ -417,8 +441,6 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (currentUser.status !== 'active') {
|
||||
return errorResponse('Account is disabled', 403);
|
||||
}
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Dedicated read rate limiting for heavy sync endpoint.
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
@@ -476,7 +498,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
// Account endpoints
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||
|
||||
@@ -17,6 +17,8 @@ const CONFIG = {
|
||||
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
||||
// Dedicated budget for GET /api/devices/knowndevice probes.
|
||||
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
|
||||
// Dedicated budget for unauthenticated public Send access endpoints.
|
||||
PUBLIC_SEND_REQUESTS_PER_MINUTE: LIMITS.rateLimit.publicSendRequestsPerMinute,
|
||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||
};
|
||||
|
||||
@@ -233,6 +235,15 @@ export class RateLimitService {
|
||||
CONFIG.API_WINDOW_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
// Budget for unauthenticated public Send access endpoints.
|
||||
async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeFixedWindowBudget(
|
||||
identifier,
|
||||
CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE,
|
||||
CONFIG.API_WINDOW_SECONDS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
|
||||
Reference in New Issue
Block a user