mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 09:13:36 +00:00
refactor(backend): move auth routes to a module
This commit is contained in:
5
server/src/config/env.ts
Normal file
5
server/src/config/env.ts
Normal 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';
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
164
server/src/modules/auth/auth.middleware.ts
Normal file
164
server/src/modules/auth/auth.middleware.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
159
server/src/modules/auth/auth.routes.ts
Normal file
159
server/src/modules/auth/auth.routes.ts
Normal 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' });
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user