From 45bc53c68f13c49b37c5580ff400762fe34a3a72 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Thu, 26 Mar 2026 13:51:01 +0300 Subject: [PATCH] refactor(backend): move auth routes to a module --- package-lock.json | 23 +++ package.json | 2 + server/src/config/env.ts | 5 + server/src/modules/auth/.gitkeep | 1 - server/src/modules/auth/auth.middleware.ts | 164 +++++++++++++++++++++ server/src/modules/auth/auth.routes.ts | 159 ++++++++++++++++++++ server/src/runner.ts | 10 +- 7 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 server/src/config/env.ts delete mode 100644 server/src/modules/auth/.gitkeep create mode 100644 server/src/modules/auth/auth.middleware.ts create mode 100644 server/src/modules/auth/auth.routes.ts diff --git a/package-lock.json b/package-lock.json index 92d416e7..cf8c1489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,9 +77,11 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", @@ -3738,6 +3740,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3851,6 +3863,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/katex": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", diff --git a/package.json b/package.json index 66d7aa57..a6c90041 100644 --- a/package.json +++ b/package.json @@ -118,9 +118,11 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", diff --git a/server/src/config/env.ts b/server/src/config/env.ts new file mode 100644 index 00000000..14df0504 --- /dev/null +++ b/server/src/config/env.ts @@ -0,0 +1,5 @@ +/** + * Environment Flag: Is Platform + * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted) + */ +export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; diff --git a/server/src/modules/auth/.gitkeep b/server/src/modules/auth/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/auth/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/auth/auth.middleware.ts b/server/src/modules/auth/auth.middleware.ts new file mode 100644 index 00000000..92d0c4a8 --- /dev/null +++ b/server/src/modules/auth/auth.middleware.ts @@ -0,0 +1,164 @@ +import type { Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { userDb } from '@/shared/database/repositories/users.js'; +import { appConfigDb } from '@/shared/database/repositories/app-config.js'; +import { IS_PLATFORM } from '@/config/env.js'; +import type { AuthenticatedRequest } from '@/shared/types/http.js'; +import { logger } from '@/shared/utils/logger.js'; +import { CreateUserResult } from '@/shared/database/types.js'; + +// Use env var if set, otherwise auto-generate a unique secret per installation +export const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret(); + +/** + * Optional API key middleware. + * If API_KEY is set in the environment, all requests to the API must include + * an 'x-api-key' header matching the configured value. + */ +export const validateApiKey = (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + // Skip API key validation if not configured + if (!process.env.API_KEY) { + next(); + return; + } + + const apiKey = req.headers['x-api-key']; + if (apiKey !== process.env.API_KEY) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + next(); +}; + +/** + * JWT authentication middleware. + * Verifies the JWT token and attaches the user to the request object. + * In Platform mode, it bypasses JWT validation and uses the first database user. + */ +export const authenticateToken = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise => { + // Platform mode: use single database user + if (IS_PLATFORM) { + try { + const user = userDb.getFirstUser(); + if (!user) { + res.status(500).json({ error: 'Platform mode: No user found in database' }); + return; + } + req.user = user; + next(); + return; + } catch (error) { + logger.error('Platform mode error:', { error }); + res.status(500).json({ error: 'Platform mode: Failed to fetch user' }); + return; + } + } + + // Normal OSS JWT validation + const authHeader = req.headers['authorization']; + let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + // Also check query param for SSE endpoints (EventSource can't set headers) + if (!token && req.query.token) { + token = req.query.token as string; + } + + if (!token) { + res.status(401).json({ error: 'Access denied. No token provided.' }); + return; + } + + try { + const decoded = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + + // Verify user still exists and is active + if (!decoded.userId) { + res.status(401).json({ error: 'Invalid token payload.' }); + return; + } + + const user = userDb.getUserById(decoded.userId as number); + if (!user) { + res.status(401).json({ error: 'Invalid token. User not found.' }); + return; + } + + // Auto-refresh: if token is past halfway through its lifetime, issue a new one + if (decoded.exp && decoded.iat) { + const now = Math.floor(Date.now() / 1000); + const halfLife = (decoded.exp - decoded.iat) / 2; + if (now > decoded.iat + halfLife) { + const newToken = generateToken({ id: user.id, username: user.username }); + res.setHeader('X-Refreshed-Token', newToken); + } + } + + req.user = user; + next(); + } catch (error) { + logger.error('Token verification error:', { error }); + res.status(403).json({ error: 'Invalid token' }); + return; + } +}; + +/** + * Generates a JWT token for the given user. + * Valid for 7 days. + */ +export const generateToken = (user: CreateUserResult): string => { + return jwt.sign( + { + userId: user.id, + username: user.username + }, + JWT_SECRET, + { expiresIn: '7d' } + ); +}; + +/** + * WebSocket authentication function. + * Validates a JWT token for WebSocket connections. + * Returns the authenticated user payload or null if invalid. + */ +export const authenticateWebSocket = (token: string | null): { userId: number; username: string; id?: number } | null => { + // Platform mode: bypass token validation, return first user + if (IS_PLATFORM) { + try { + const user = userDb.getFirstUser(); + if (user) { + return { id: user.id, userId: user.id, username: user.username }; + } + return null; + } catch (error) { + logger.error('Platform mode WebSocket error:', { error }); + return null; + } + } + + // Normal OSS JWT validation + if (!token) { + return null; + } + + try { + const decoded = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; + + if (!decoded.userId) return null; + + // Verify user actually exists in database (matches REST authenticateToken behavior) + const user = userDb.getUserById(decoded.userId as number); + if (!user) { + return null; + } + return { userId: user.id, username: user.username }; + } catch (error) { + logger.error('WebSocket token verification error:', { error }); + return null; + } +}; diff --git a/server/src/modules/auth/auth.routes.ts b/server/src/modules/auth/auth.routes.ts new file mode 100644 index 00000000..78f6baef --- /dev/null +++ b/server/src/modules/auth/auth.routes.ts @@ -0,0 +1,159 @@ +import express, { type Request, type Response } from 'express'; +import bcrypt from 'bcrypt'; +import { userDb } from '@/shared/database/repositories/users.js'; +import { getConnection } from '@/shared/database/connection.js'; +import { generateToken, authenticateToken } from './auth.middleware.js'; +import type { AuthenticatedRequest } from '@/shared/types/http.js'; +import { logger } from '@/shared/utils/logger.js'; + +export const authRoutes = express.Router(); + +/** + * Check auth status and setup requirements + * GET /api/auth/status + */ +authRoutes.get('/status', (req: Request, res: Response) => { + try { + const hasUsers = userDb.hasUsers(); + res.json({ + needsSetup: !hasUsers, + isAuthenticated: false // Will be overridden by frontend if token exists + }); + } catch (error) { + logger.error('Auth status error:', { error }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * User registration (setup) - only allowed if no users exist + * POST /api/auth/register + */ +authRoutes.post('/register', async (req: Request, res: Response): Promise => { + try { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + if (username.length < 3 || password.length < 6) { + res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' }); + return; + } + + const db = getConnection(); + + // Use a transaction to prevent race conditions + db.prepare('BEGIN').run(); + try { + // Check if users already exist (only allow one user) + const hasUsers = userDb.hasUsers(); + if (hasUsers) { + db.prepare('ROLLBACK').run(); + res.status(403).json({ error: 'User already exists. This is a single-user system.' }); + return; + } + + // Hash password + const saltRounds = 12; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // Create user + const user = userDb.createUser(username, passwordHash); + + // Generate token + const token = generateToken(user); + + db.prepare('COMMIT').run(); + + // Update last login (non-fatal, outside transaction) + userDb.updateLastLogin(Number(user.id)); + + res.json({ + success: true, + user: { id: user.id, username: user.username }, + token + }); + } catch (error) { + db.prepare('ROLLBACK').run(); + throw error; + } + + } catch (error: any) { + logger.error('Registration error:', { error }); + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + res.status(409).json({ error: 'Username already exists' }); + } else { + res.status(500).json({ error: 'Internal server error' }); + } + } +}); + +/** + * User login + * POST /api/auth/login + */ +authRoutes.post('/login', async (req: Request, res: Response): Promise => { + try { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + // Get user from database + const user = userDb.getUserByUsername(username); + if (!user) { + res.status(401).json({ error: 'Invalid username or password' }); + return; + } + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + res.status(401).json({ error: 'Invalid username or password' }); + return; + } + + // Generate token + const token = generateToken(user); + + // Update last login + userDb.updateLastLogin(user.id); + + res.json({ + success: true, + user: { id: user.id, username: user.username }, + token + }); + + } catch (error) { + logger.error('Login error:', { error }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * Get current user (protected route) + * GET /api/auth/user + */ +authRoutes.get('/user', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + res.json({ + user: req.user + }); +}); + +/** + * Logout (client-side token removal, but this endpoint can be used for logging) + * POST /api/auth/logout + */ +authRoutes.post('/logout', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + // In a simple JWT system, logout is mainly client-side + // This endpoint exists for consistency and potential future logging + res.json({ success: true, message: 'Logged out successfully' }); +}); diff --git a/server/src/runner.ts b/server/src/runner.ts index 48e4bab2..fad562ed 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -11,12 +11,11 @@ import { initializeDatabase } from '@/shared/database/init-db.js'; import { initializeWatcher } from '@/modules/sessions/sessions.watcher.js'; import { getConnectableHost } from '@/shared/utils/networkHosts.js'; import { logger } from '@/shared/utils/logger.js'; +import { authRoutes } from '@/modules/auth/auth.routes.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -console.log("----------------Hello there, Refactored Runner!-------------------"); - const app = express(); const server = http.createServer(app); @@ -49,7 +48,7 @@ app.use(express.urlencoded({ limit: '50mb', extended: true })); // Simple logging middleware to track all incoming requests -// TODO: REMOVE THIS +// TODO: REMOVE THIS LATER app.use((req, res, next) => { // Only log API endpoints to avoid spamming the console with static file requests if (req.url.startsWith('/')) { @@ -59,6 +58,8 @@ app.use((req, res, next) => { }); +app.use('/api/auth', authRoutes); + // This matches files found in the root public folder (like api-docs.html when we run `/api-docs.html`). // If the file is found, it's automatically sent. If it is not, it passes it to the next route checker. // This will run in production as well as development URLs. @@ -66,6 +67,7 @@ app.use(express.static(path.join(__dirname, '../../public'))); // If the file is not in the public directory, it's checked if it exists in the root dist folder which was built from vite. // * Note: If the request is for `/` (i.e. homepage), `express.static` automatically maps the request to `/index.html`. +// This will fetch /index.html for `/` calls in production. app.use(express.static(path.join(__dirname, '../../dist'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { @@ -83,7 +85,7 @@ app.use(express.static(path.join(__dirname, '../../dist'), { })); // Serve React app for all other routes (excluding static files) -// This will match routes like /sessions in production builds +// This will match routes like /sessions (UI navigation routes) in production builds app.get('*', (req, res) => { // Skip requests for static assets (files with extensions) if (path.extname(req.path)) {