update
This commit is contained in:
@@ -93,7 +93,7 @@ class AuthController {
|
||||
try {
|
||||
await authentikService.verifyIdToken(tokens.id_token);
|
||||
} catch (error) {
|
||||
logger.warn('ID token verification failed — continuing with userinfo', { error });
|
||||
logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,31 @@ class AuthController {
|
||||
metadata: { new_account: true },
|
||||
});
|
||||
} else {
|
||||
// User exists, update last login
|
||||
// User exists — check active status BEFORE any mutations
|
||||
if (!user.is_active) {
|
||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||
|
||||
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',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User is active, proceed with login updates
|
||||
logger.info('Existing user logging in', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
@@ -148,12 +172,13 @@ class AuthController {
|
||||
|
||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// Refresh profile fields from Authentik on every login
|
||||
// Refresh profile fields from Authentik on every login (including profile picture)
|
||||
await userService.updateUser(user.id, {
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
profile_picture_url: userInfo.picture || null,
|
||||
});
|
||||
|
||||
// Audit: returning user login
|
||||
@@ -174,31 +199,6 @@ class AuthController {
|
||||
// Extract normalised names once for use in the response
|
||||
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// 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',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Generate internal JWT token
|
||||
const role = await getUserRole(user.id);
|
||||
const accessToken = tokenService.generateToken({
|
||||
|
||||
@@ -252,21 +252,26 @@ class EquipmentController {
|
||||
// Determine which category to check permissions against
|
||||
const groups = getUserGroups(req);
|
||||
if (!groups.includes('dashboard_admin')) {
|
||||
// If kategorie_id is being changed, check against the new category; otherwise fetch existing
|
||||
let kategorieId = parsed.data.kategorie_id;
|
||||
if (!kategorieId) {
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
kategorieId = existing.kategorie_id;
|
||||
// Always fetch existing equipment to check old category permission
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const allowed = await checkCategoryPermission(kategorieId, groups);
|
||||
if (!allowed) {
|
||||
// Check permission against the OLD category (must be allowed to move FROM it)
|
||||
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||
if (!allowedOld) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
// If kategorie_id is being changed, also check permission against the NEW category
|
||||
if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) {
|
||||
const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||
if (!allowedNew) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
|
||||
if (!equipment) {
|
||||
|
||||
@@ -161,7 +161,7 @@ class EventsController {
|
||||
// -------------------------------------------------------------------------
|
||||
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50);
|
||||
const userGroups = getUserGroups(req);
|
||||
const data = await eventsService.getUpcomingEvents(limit, userGroups);
|
||||
res.json({ success: true, data });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
||||
import incidentService from '../services/incident.service';
|
||||
import logger from '../utils/logger';
|
||||
import { AppError } from '../middleware/error.middleware';
|
||||
import { AppRole, hasPermission } from '../middleware/rbac.middleware';
|
||||
import { AppRole, hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
|
||||
import {
|
||||
CreateEinsatzSchema,
|
||||
UpdateEinsatzSchema,
|
||||
@@ -75,16 +75,22 @@ class IncidentController {
|
||||
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
|
||||
// UUID validation
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const incident = await incidentService.getIncidentById(id);
|
||||
|
||||
if (!incident) {
|
||||
throw new AppError('Einsatz nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Role-based redaction: only Kommandant+ can see full bericht_text
|
||||
const canReadBerichtText =
|
||||
req.userRole !== undefined &&
|
||||
hasPermission(req.userRole, 'incidents:read_bericht_text');
|
||||
// Role-based redaction: self-contained role resolution (no middleware dependency)
|
||||
const role = resolveRequestRole(req);
|
||||
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
|
||||
|
||||
const responseData = {
|
||||
...incident,
|
||||
|
||||
@@ -60,8 +60,8 @@ class MemberController {
|
||||
search,
|
||||
status: normalizeArray(statusParam) as any,
|
||||
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
|
||||
page: page ? parseInt(page, 10) || 1 : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import nextcloudService from '../services/nextcloud.service';
|
||||
import userService from '../services/user.service';
|
||||
@@ -216,13 +217,20 @@ class NextcloudController {
|
||||
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
|
||||
return;
|
||||
}
|
||||
// Path traversal protection
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.includes('..') || !normalized.startsWith('/')) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' });
|
||||
return;
|
||||
}
|
||||
const response = await nextcloudService.downloadFile(
|
||||
filePath,
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
|
||||
const contentDisposition = response.headers['content-disposition'] ?? `attachment; filename="${req.params.fileId}"`;
|
||||
const contentDisposition = response.headers['content-disposition']
|
||||
?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
if (response.headers['content-length']) {
|
||||
|
||||
@@ -78,6 +78,17 @@ class SettingsController {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const preferences = req.body;
|
||||
|
||||
// Basic validation — reject excessively large or non-object payloads
|
||||
if (typeof preferences !== 'object' || preferences === null || Array.isArray(preferences)) {
|
||||
res.status(400).json({ success: false, message: 'Preferences must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(preferences).length > 10_000) {
|
||||
res.status(400).json({ success: false, message: 'Preferences payload too large' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET preferences = $1 WHERE id = $2',
|
||||
[JSON.stringify(preferences), userId]
|
||||
|
||||
Reference in New Issue
Block a user