add features
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user