mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
- Revised SameSite attribute for session cookies to clarify its behavior in documentation. - Streamlined cookie clearing logic in the authentication route by utilizing `getSessionCookieOptions()`. - Enhanced the login view to support aborting server checks, improving responsiveness during component unmounting. - Ensured proper handling of server check retries with abort signal integration for better user experience.
249 lines
6.7 KiB
TypeScript
249 lines
6.7 KiB
TypeScript
/**
|
|
* Auth routes - Login, logout, and status endpoints
|
|
*
|
|
* Security model:
|
|
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
|
|
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
|
|
*
|
|
* The session cookie is:
|
|
* - HTTP-only: JavaScript cannot read it (protects against XSS)
|
|
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
|
|
*
|
|
* Mounted at /api/auth in the main server (BEFORE auth middleware).
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import type { Request } from 'express';
|
|
import {
|
|
validateApiKey,
|
|
createSession,
|
|
invalidateSession,
|
|
getSessionCookieOptions,
|
|
getSessionCookieName,
|
|
isRequestAuthenticated,
|
|
createWsConnectionToken,
|
|
} from '../../lib/auth.js';
|
|
|
|
// Rate limiting configuration
|
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
|
|
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
|
|
|
|
// Check if we're in test mode - disable rate limiting for E2E tests
|
|
const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
|
|
|
|
// In-memory rate limit tracking (resets on server restart)
|
|
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
|
|
|
|
// Clean up old rate limit entries periodically (every 5 minutes)
|
|
setInterval(
|
|
() => {
|
|
const now = Date.now();
|
|
loginAttempts.forEach((data, ip) => {
|
|
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
});
|
|
},
|
|
5 * 60 * 1000
|
|
);
|
|
|
|
/**
|
|
* Get client IP address from request
|
|
* Handles X-Forwarded-For header for reverse proxy setups
|
|
*/
|
|
function getClientIp(req: Request): string {
|
|
const forwarded = req.headers['x-forwarded-for'];
|
|
if (forwarded) {
|
|
// X-Forwarded-For can be a comma-separated list; take the first (original client)
|
|
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
|
|
return forwardedIp.trim();
|
|
}
|
|
return req.ip || req.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Check if an IP is rate limited
|
|
* Returns { limited: boolean, retryAfter?: number }
|
|
*/
|
|
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
|
|
const now = Date.now();
|
|
const attempt = loginAttempts.get(ip);
|
|
|
|
if (!attempt) {
|
|
return { limited: false };
|
|
}
|
|
|
|
// Check if window has expired
|
|
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
|
loginAttempts.delete(ip);
|
|
return { limited: false };
|
|
}
|
|
|
|
// Check if over limit
|
|
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
|
|
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
|
|
return { limited: true, retryAfter };
|
|
}
|
|
|
|
return { limited: false };
|
|
}
|
|
|
|
/**
|
|
* Record a login attempt for rate limiting
|
|
*/
|
|
function recordLoginAttempt(ip: string): void {
|
|
const now = Date.now();
|
|
const attempt = loginAttempts.get(ip);
|
|
|
|
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
|
|
// Start new window
|
|
loginAttempts.set(ip, { count: 1, windowStart: now });
|
|
} else {
|
|
// Increment existing window
|
|
attempt.count++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create auth routes
|
|
*
|
|
* @returns Express Router with auth endpoints
|
|
*/
|
|
export function createAuthRoutes(): Router {
|
|
const router = Router();
|
|
|
|
/**
|
|
* GET /api/auth/status
|
|
*
|
|
* Returns whether the current request is authenticated.
|
|
* Used by the UI to determine if login is needed.
|
|
*/
|
|
router.get('/status', (req, res) => {
|
|
const authenticated = isRequestAuthenticated(req);
|
|
res.json({
|
|
success: true,
|
|
authenticated,
|
|
required: true,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/auth/login
|
|
*
|
|
* Validates the API key and sets a session cookie.
|
|
* Body: { apiKey: string }
|
|
*
|
|
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
|
|
*/
|
|
router.post('/login', async (req, res) => {
|
|
const clientIp = getClientIp(req);
|
|
|
|
// Skip rate limiting in test mode to allow parallel E2E tests
|
|
if (!isTestMode) {
|
|
// Check rate limit before processing
|
|
const rateLimit = checkRateLimit(clientIp);
|
|
if (rateLimit.limited) {
|
|
res.status(429).json({
|
|
success: false,
|
|
error: 'Too many login attempts. Please try again later.',
|
|
retryAfter: rateLimit.retryAfter,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { apiKey } = req.body as { apiKey?: string };
|
|
|
|
if (!apiKey) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: 'API key is required.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Record this attempt (only for actual API key validation attempts, skip in test mode)
|
|
if (!isTestMode) {
|
|
recordLoginAttempt(clientIp);
|
|
}
|
|
|
|
if (!validateApiKey(apiKey)) {
|
|
res.status(401).json({
|
|
success: false,
|
|
error: 'Invalid API key.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create session and set cookie
|
|
const sessionToken = await createSession();
|
|
const cookieOptions = getSessionCookieOptions();
|
|
const cookieName = getSessionCookieName();
|
|
|
|
res.cookie(cookieName, sessionToken, cookieOptions);
|
|
res.json({
|
|
success: true,
|
|
message: 'Logged in successfully.',
|
|
// Return token for explicit header-based auth (works around cross-origin cookie issues)
|
|
token: sessionToken,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* GET /api/auth/token
|
|
*
|
|
* Generates a short-lived WebSocket connection token if the user has a valid session.
|
|
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
|
|
* The token is NOT the session cookie value - it's a separate, short-lived token.
|
|
*/
|
|
router.get('/token', (req, res) => {
|
|
// Validate the session is still valid (via cookie, API key, or session token header)
|
|
if (!isRequestAuthenticated(req)) {
|
|
res.status(401).json({
|
|
success: false,
|
|
error: 'Authentication required.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Generate a new short-lived WebSocket connection token
|
|
const wsToken = createWsConnectionToken();
|
|
|
|
res.json({
|
|
success: true,
|
|
token: wsToken,
|
|
expiresIn: 300, // 5 minutes in seconds
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/auth/logout
|
|
*
|
|
* Clears the session cookie and invalidates the session.
|
|
*/
|
|
router.post('/logout', async (req, res) => {
|
|
const cookieName = getSessionCookieName();
|
|
const sessionToken = req.cookies?.[cookieName] as string | undefined;
|
|
|
|
if (sessionToken) {
|
|
await invalidateSession(sessionToken);
|
|
}
|
|
|
|
// Clear the cookie by setting it to empty with immediate expiration
|
|
// Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
|
|
// in cross-origin development environments
|
|
res.cookie(cookieName, '', {
|
|
...getSessionCookieOptions(),
|
|
maxAge: 0,
|
|
expires: new Date(0),
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Logged out successfully.',
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|