add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:47:20 +01:00
parent 44e22a9fc6
commit c5e8337a69
11 changed files with 1554 additions and 194 deletions

View File

@@ -53,11 +53,21 @@ app.get('/health', (_req: Request, res: Response) => {
});
// API routes
import authRoutes from './routes/auth.routes';
import userRoutes from './routes/user.routes';
import authRoutes from './routes/auth.routes';
import userRoutes from './routes/user.routes';
import memberRoutes from './routes/member.routes';
import adminRoutes from './routes/admin.routes';
import trainingRoutes from './routes/training.routes';
import vehicleRoutes from './routes/vehicle.routes';
import incidentRoutes from './routes/incident.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
app.use('/api/members', memberRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/training', trainingRoutes);
app.use('/api/vehicles', vehicleRoutes);
app.use('/api/incidents', incidentRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -4,6 +4,8 @@ import tokenService from '../services/token.service';
import userService from '../services/user.service';
import logger from '../utils/logger';
import { AuthRequest } from '../types/auth.types';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
class AuthController {
/**
@@ -11,6 +13,9 @@ class AuthController {
* POST /api/auth/callback
*/
async handleCallback(req: Request, res: Response): Promise<void> {
const ip = extractIp(req);
const userAgent = extractUserAgent(req);
try {
const { code } = req.body as AuthRequest;
@@ -46,32 +51,75 @@ class AuthController {
if (!user) {
// User doesn't exist, create new user
logger.info('Creating new user from Authentik', {
sub: userInfo.sub,
sub: userInfo.sub,
email: userInfo.email,
});
user = await userService.createUser({
email: userInfo.email,
authentik_sub: userInfo.sub,
email: userInfo.email,
authentik_sub: userInfo.sub,
preferred_username: userInfo.preferred_username,
given_name: userInfo.given_name,
family_name: userInfo.family_name,
name: userInfo.name,
given_name: userInfo.given_name,
family_name: userInfo.family_name,
name: userInfo.name,
profile_picture_url: userInfo.picture,
});
// Audit: first-ever login (user record creation)
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: { event: 'first_login', email: user.email },
ip_address: ip,
user_agent: userAgent,
metadata: { new_account: true },
});
} else {
// User exists, update last login
logger.info('Existing user logging in', {
userId: user.id,
email: user.email,
email: user.email,
});
await userService.updateLastLogin(user.id);
// Audit: returning user login
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
});
}
// Check if user is active
if (!user.is_active) {
logger.warn('Inactive user attempted login', { userId: user.id });
// Audit the denied login attempt
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.PERMISSION_DENIED,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: { reason: 'account_inactive' },
});
res.status(403).json({
success: false,
message: 'User account is inactive',
@@ -81,20 +129,20 @@ class AuthController {
// Step 5: Generate internal JWT token
const accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
});
// Generate refresh token
const refreshToken = tokenService.generateRefreshToken({
userId: user.id,
email: user.email,
email: user.email,
});
logger.info('User authenticated successfully', {
userId: user.id,
email: user.email,
email: user.email,
});
// Step 6: Return tokens and user info
@@ -105,20 +153,37 @@ class AuthController {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
id: user.id,
email: user.email,
name: user.name,
preferredUsername: user.preferred_username,
givenName: user.given_name,
familyName: user.family_name,
givenName: user.given_name,
familyName: user.family_name,
profilePictureUrl: user.profile_picture_url,
isActive: user.is_active,
isActive: user.is_active,
},
},
});
} catch (error) {
logger.error('OAuth callback error', { error });
// Audit the failed login attempt (user_id unknown at this point)
auditService.logAudit({
user_id: null,
user_email: null,
action: AuditAction.PERMISSION_DENIED,
resource_type: AuditResourceType.SYSTEM,
resource_id: null,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {
reason: 'oauth_callback_error',
error: error instanceof Error ? error.message : 'unknown',
},
});
const message =
error instanceof Error ? error.message : 'Authentication failed';
@@ -134,14 +199,29 @@ class AuthController {
* POST /api/auth/logout
*/
async handleLogout(req: Request, res: Response): Promise<void> {
try {
// In a stateless JWT setup, logout is handled client-side by removing the token
// However, we can log the event for audit purposes
const ip = extractIp(req);
const userAgent = extractUserAgent(req);
try {
// In a stateless JWT setup, logout is handled client-side by removing
// the token. We log the event for GDPR accountability.
if (req.user) {
logger.info('User logged out', {
userId: req.user.id,
email: req.user.email,
email: req.user.email,
});
auditService.logAudit({
user_id: req.user.id,
user_email: req.user.email,
action: AuditAction.LOGOUT,
resource_type: AuditResourceType.USER,
resource_id: req.user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
});
}
@@ -215,14 +295,14 @@ class AuthController {
// Generate new access token
const accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
});
logger.info('Token refreshed successfully', {
userId: user.id,
email: user.email,
email: user.email,
});
res.status(200).json({

View File

@@ -3,15 +3,46 @@ import tokenService from '../services/token.service';
import userService from '../services/user.service';
import logger from '../utils/logger';
import { JwtPayload } from '../types/auth.types';
import { auditPermissionDenied } from './audit.middleware';
import { AuditResourceType } from '../services/audit.service';
// ---------------------------------------------------------------------------
// Application roles — extend as needed when Authentik group mapping is added
// ---------------------------------------------------------------------------
export type AppRole = 'admin' | 'member' | 'viewer';
export const Permission = {
ADMIN_ACCESS: 'admin:access',
MEMBER_WRITE: 'member:write',
MEMBER_READ: 'member:read',
INCIDENT_WRITE:'incident:write',
INCIDENT_READ: 'incident:read',
EXPORT: 'export',
} as const;
export type Permission = typeof Permission[keyof typeof Permission];
// Simple permission → required role mapping.
// Adjust once Authentik group sync is implemented.
const PERMISSION_ROLES: Record<Permission, AppRole[]> = {
'admin:access': ['admin'],
'member:write': ['admin', 'member'],
'member:read': ['admin', 'member', 'viewer'],
'incident:write': ['admin', 'member'],
'incident:read': ['admin', 'member', 'viewer'],
'export': ['admin'],
};
// Extend Express Request type to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string; // UUID
email: string;
id: string; // UUID
email: string;
authentikSub: string;
role?: AppRole; // populated when role is stored in DB / JWT
};
}
}
@@ -106,6 +137,60 @@ export const authenticate = async (
}
};
// ---------------------------------------------------------------------------
// Role-based access control middleware
// ---------------------------------------------------------------------------
/**
* requirePermission — factory that returns Express middleware enforcing a
* specific permission. Must be placed after `authenticate` in the chain.
*
* Usage:
* router.get('/admin/audit-log', authenticate, requirePermission('admin:access'), handler);
*
* When access is denied, a PERMISSION_DENIED audit entry is written before
* the 403 response is sent.
*
* NOTE: Until Authentik group → role mapping is persisted to the users table
* or JWT, this middleware checks req.user.role. Temporary workaround:
* hard-code specific admin user IDs via the ADMIN_USER_IDS env variable, OR
* add a `role` column to the users table (recommended).
*/
export const requirePermission = (permission: Permission) => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({ success: false, message: 'Not authenticated' });
return;
}
const userRole: AppRole = req.user.role ?? 'viewer';
const allowedRoles = PERMISSION_ROLES[permission];
if (!allowedRoles.includes(userRole)) {
logger.warn('Permission denied', {
userId: req.user.id,
permission,
userRole,
path: req.path,
});
// Audit the denied access — fire-and-forget
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_role: userRole,
});
res.status(403).json({
success: false,
message: 'Insufficient permissions',
});
return;
}
next();
};
};
/**
* Optional authentication middleware
* Attaches user if token is valid, but doesn't require it

View File

@@ -2,6 +2,7 @@ import app from './app';
import environment from './config/environment';
import logger from './utils/logger';
import { testConnection, closePool } from './config/database';
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
const startServer = async (): Promise<void> => {
try {
@@ -13,12 +14,15 @@ const startServer = async (): Promise<void> => {
logger.warn('Database connection failed - server will start but database operations may fail');
}
// Start the GDPR IP anonymisation job
startAuditCleanupJob();
// Start the server
const server = app.listen(environment.port, () => {
logger.info('Server started successfully', {
port: environment.port,
port: environment.port,
environment: environment.nodeEnv,
database: dbConnected ? 'connected' : 'disconnected',
database: dbConnected ? 'connected' : 'disconnected',
});
});
@@ -26,6 +30,9 @@ const startServer = async (): Promise<void> => {
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} received. Starting graceful shutdown...`);
// Stop scheduled jobs first
stopAuditCleanupJob();
server.close(async () => {
logger.info('HTTP server closed');