refactor(backend): move auth routes to a module

This commit is contained in:
Haileyesus
2026-03-26 13:51:01 +03:00
parent 24abcef110
commit 45bc53c68f
7 changed files with 359 additions and 5 deletions

5
server/src/config/env.ts Normal file
View File

@@ -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';

View File

@@ -1 +0,0 @@

View File

@@ -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<void> => {
// 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;
}
};

View File

@@ -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<void> => {
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<void> => {
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' });
});

View File

@@ -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)) {