mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
refactor(backend): extract auth/MFA/SSO routers from index.ts (phase 4a-2) (#735)
Second slice of Phase 4. Pulls the three auth-family route groups out of
index.ts into focused routers. All handlers move verbatim; index.ts drops
~845 lines.
New route files:
- routes/auth.ts: /api/auth core (status, setup, login, password, logout,
check, generate-node-token)
- routes/mfa.ts: /api/auth/login/mfa + full /api/auth/mfa/* surface
(status, enroll start/confirm, disable, backup-codes/regenerate,
sso-bypass)
- routes/sso.ts: /api/auth/sso/{providers,ldap,oidc/:provider/authorize,
oidc/:provider/callback} + getSSOBaseUrl helper. Module-load calls
SSOService.getInstance().seedFromEnv() so env-seeded providers are
available on the first request.
Shared lifts:
- helpers/constants.ts: MFA_REPLAY_TTL_MS + MFA_REPLAY_PURGE_INTERVAL_MS
(used by mfa.ts and the startup purge timer in index.ts) and
BCRYPT_SALT_ROUNDS (shared between setup and password-change handlers).
- middleware/auth.ts: new reissueSessionAfterTokenBump(req, res, userId)
helper collapses three copies of "bump → fetch user → re-sign cookie"
across auth.ts (password change) and mfa.ts (enrol confirm, disable).
Code review fixes:
- File-local requireEnrolledMfaUser helper in mfa.ts eliminates four
copies of "auth check + rejectApiTokenScope + load enrolled MFA" with
near-identical shape.
- Applied BCRYPT_SALT_ROUNDS to auth.ts setup + password handlers.
Mount order in index.ts: authRouter / mfaRouter / ssoRouter sit before
authGate because login / setup / SSO-callback are public; handlers that
need auth use authMiddleware directly on the route.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
authMiddleware,
|
||||
issueSessionCookie,
|
||||
issueMfaPendingCookie,
|
||||
clearMfaPendingCookie,
|
||||
reissueSessionAfterTokenBump,
|
||||
} from '../middleware/auth';
|
||||
import { authRateLimiter } from '../middleware/rateLimiters';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import {
|
||||
BCRYPT_SALT_ROUNDS,
|
||||
COOKIE_NAME,
|
||||
MFA_PENDING_COOKIE_NAME,
|
||||
MFA_PENDING_SCOPE,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from '../helpers/constants';
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// Check if setup is needed, and whether the caller currently holds a valid
|
||||
// `mfa_pending` partial-auth cookie (so the frontend can route to the
|
||||
// challenge screen on a page reload mid-flow, e.g. after an OIDC redirect).
|
||||
authRouter.get('/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
|
||||
let mfaPending = false;
|
||||
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
||||
if (mfaCookie && settings.auth_jwt_secret) {
|
||||
try {
|
||||
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
|
||||
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
|
||||
} catch {
|
||||
// Expired or invalid cookie; treat as no pending challenge.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ needsSetup, mfaPending });
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.json({ needsSetup: true, mfaPending: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
if (!needsSetup) {
|
||||
res.status(400).json({ error: 'Setup has already been completed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ error: 'All fields are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
res.status(400).json({ error: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
const jwtSecret = crypto.randomBytes(64).toString('hex');
|
||||
dbSvc.updateGlobalSetting('auth_username', username);
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
|
||||
|
||||
// Create admin user in users table
|
||||
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
|
||||
res.json({ success: true, message: 'Setup completed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
res.status(500).json({ error: 'Failed to complete setup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
if (user) {
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (isValid) {
|
||||
const settings = db.getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('JWT secret missing from DB');
|
||||
|
||||
// If MFA is enabled for this user, issue only the partial-auth cookie
|
||||
// and signal the client to complete the TOTP challenge. No session
|
||||
// cookie is set until the second factor is verified.
|
||||
const mfa = db.getUserMfa(user.id);
|
||||
if (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
|
||||
}
|
||||
if (mfa?.enabled) {
|
||||
issueMfaPendingCookie(res, req, user, jwtSecret);
|
||||
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
|
||||
res.json({ success: true, mfaRequired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, jwtSecret);
|
||||
console.log('[Auth] Login successful:', user.username);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[Auth] Login failed for username:', username);
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Any authenticated user can change their own password.
|
||||
authRouter.put('/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot change passwords.')) return;
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
if (!oldPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Old password and new password are required' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const user = dbSvc.getUserByUsername(req.user!.username);
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: 'Invalid old password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
||||
dbSvc.updateUser(user.id, { password_hash: newHash });
|
||||
// Keep global_settings in sync for backward compat.
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
|
||||
// Invalidate all other sessions for this user, then re-issue the caller's
|
||||
// cookie so the current session survives the token-version bump.
|
||||
dbSvc.bumpTokenVersion(user.id);
|
||||
reissueSessionAfterTokenBump(req, res, user.id);
|
||||
console.log('[Auth] Password changed by:', req.user!.username);
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Auth] Password update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update password' });
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.post('/logout', (req: Request, res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
// Clear any partial-auth cookie so a user aborting the MFA challenge is
|
||||
// returned to a fully unauthenticated state.
|
||||
clearMfaPendingCookie(res, req);
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
authRouter.get('/check', authMiddleware, (req: Request, res: Response): void => {
|
||||
res.json({ authenticated: true, user: req.user });
|
||||
});
|
||||
|
||||
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication.
|
||||
authRouter.post('/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot generate node tokens.')) return;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
|
||||
return;
|
||||
}
|
||||
// Default 1-year expiry; admin should rotate tokens periodically.
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to generate node token') });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user