fix: comprehensive quality improvements
- Fix 2 backend test failures (projectFolders validation, StressTest timeout) - Fix frontend test dependency (@testing-library/dom) - Delete App.tsx.bak from repo - Add rate limiting on auth endpoints (login: 10/min, register: 3/min) - Add .env.example with all environment variables - Update BAUPLAN.md with accurate phase status (16/21 phases implemented) - Add OpenAPI/Swagger documentation (docs/openapi.yaml) - Add E2E workflow test (15 steps: register→project→drawing→layer→element→CRUD→delete) - Add Forgejo CI/CD pipeline (.forgejo/workflows/ci.yml) - Improve .gitignore (db-wal, .env.*, *.bak, coverage) - Add security comment for default user in schema.sql All 628 tests passing (254 backend + 374 frontend)
This commit is contained in:
@@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
);
|
||||
|
||||
-- ─── Default User (seed) ────────────────────────────
|
||||
-- Default user for foreign key constraints. Cannot login (empty password_hash
|
||||
-- means bcrypt.compare always fails). Real users must register explicitly.
|
||||
INSERT OR IGNORE INTO users (id, email, password_hash, name, role) VALUES ('user-default', 'default@webcad.local', '', 'Default User', 'admin');
|
||||
|
||||
-- ─── Projects ───────────────────────────────────────
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../auth/AuthService.js';
|
||||
import { checkLoginRateLimit, checkRegisterRateLimit, getClientIp } from '../utils/rateLimiter.js';
|
||||
|
||||
export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthService) {
|
||||
// Register new user
|
||||
fastify.post('/api/auth/register', async (request, reply) => {
|
||||
const ip = getClientIp(request);
|
||||
const rateLimitErr = checkRegisterRateLimit(ip);
|
||||
if (rateLimitErr) return reply.code(429).send({ error: rateLimitErr });
|
||||
|
||||
const { email, password, name, role } = request.body as {
|
||||
email?: string; password?: string; name?: string; role?: string;
|
||||
};
|
||||
@@ -22,6 +27,10 @@ export function registerAuthRoutes(fastify: FastifyInstance, authService: AuthSe
|
||||
|
||||
// Login
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const ip = getClientIp(request);
|
||||
const rateLimitErr = checkLoginRateLimit(ip);
|
||||
if (rateLimitErr) return reply.code(429).send({ error: rateLimitErr });
|
||||
|
||||
const { email, password } = request.body as { email?: string; password?: string };
|
||||
if (!email || !password) {
|
||||
return reply.code(400).send({ error: 'Email and password are required' });
|
||||
|
||||
@@ -68,6 +68,8 @@ export function registerProjectFolderRoutes(fastify: FastifyInstance, db: Databa
|
||||
const nameErr = validateName(body.name, 'name');
|
||||
if (nameErr) return reply.code(400).send({ error: nameErr });
|
||||
updates.name = body.name;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'name is required' });
|
||||
}
|
||||
if (body.parent_id !== undefined) updates.parent_id = body.parent_id;
|
||||
const updated = db.updateProjectFolder(id, updates);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter for auth endpoints.
|
||||
* Limits requests per IP address within a time window.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const MAX_REQUESTS = 10; // max requests per window per IP
|
||||
const MAX_REGISTER = 3; // stricter for registration
|
||||
|
||||
const loginLimiter = new Map<string, RateLimitEntry>();
|
||||
const registerLimiter = new Map<string, RateLimitEntry>();
|
||||
|
||||
/**
|
||||
* Check rate limit for a given IP and limiter map.
|
||||
* Returns null if allowed, or an error message if rate limit exceeded.
|
||||
*/
|
||||
function checkRateLimit(
|
||||
ip: string,
|
||||
limiter: Map<string, RateLimitEntry>,
|
||||
maxRequests: number,
|
||||
): string | null {
|
||||
const now = Date.now();
|
||||
const entry = limiter.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
limiter.set(ip, { count: 1, resetTime: now + WINDOW_MS });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.count >= maxRequests) {
|
||||
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
|
||||
return `Rate limit exceeded. Try again in ${retryAfter} seconds.`;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check login rate limit for an IP.
|
||||
*/
|
||||
export function checkLoginRateLimit(ip: string): string | null {
|
||||
return checkRateLimit(ip, loginLimiter, MAX_REQUESTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check register rate limit for an IP.
|
||||
*/
|
||||
export function checkRegisterRateLimit(ip: string): string | null {
|
||||
return checkRateLimit(ip, registerLimiter, MAX_REGISTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP from Fastify request.
|
||||
*/
|
||||
export function getClientIp(request: { ip: string; headers: Record<string, string | string[] | undefined> }): string {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0].trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
Reference in New Issue
Block a user