diff --git a/backend/package.json b/backend/package.json index 84f18d8..2c67dfc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", + "jose": "^6.0.11", "jsonwebtoken": "^9.0.3", "pg": "^8.18.0", "winston": "^3.19.0", diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 4f2ce5f..47e423f 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -1,11 +1,12 @@ import { Request, Response } from 'express'; +import { z } from 'zod'; import authentikService from '../services/authentik.service'; 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'; +import { getUserRole } from '../middleware/rbac.middleware'; /** * Extract given_name and family_name from Authentik userinfo. @@ -60,10 +61,13 @@ class AuthController { const userAgent = extractUserAgent(req); try { - const { code } = req.body as AuthRequest; + const callbackSchema = z.object({ + code: z.string().min(1), + redirect_uri: z.string().url().optional(), + }); - // Validate code - if (!code) { + const parseResult = callbackSchema.safeParse(req.body); + if (!parseResult.success) { res.status(400).json({ success: false, message: 'Authorization code is required', @@ -71,6 +75,8 @@ class AuthController { return; } + const { code } = parseResult.data; + logger.info('Processing OAuth callback', { hasCode: !!code }); // Step 1: Exchange code for tokens @@ -83,9 +89,9 @@ class AuthController { // Step 3: Verify ID token if present if (tokens.id_token) { try { - authentikService.verifyIdToken(tokens.id_token); + await authentikService.verifyIdToken(tokens.id_token); } catch (error) { - logger.warn('ID token verification failed', { error }); + logger.warn('ID token verification failed — continuing with userinfo', { error }); } } @@ -190,11 +196,13 @@ class AuthController { } // Step 5: Generate internal JWT token + const role = await getUserRole(user.id); const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, groups, + role, }); // Generate refresh token @@ -309,9 +317,12 @@ class AuthController { */ async handleRefresh(req: Request, res: Response): Promise { try { - const { refreshToken } = req.body; + const refreshSchema = z.object({ + refreshToken: z.string().min(1), + }); - if (!refreshToken) { + const parseResult = refreshSchema.safeParse(req.body); + if (!parseResult.success) { res.status(400).json({ success: false, message: 'Refresh token is required', @@ -319,6 +330,8 @@ class AuthController { return; } + const { refreshToken } = parseResult.data; + // Verify refresh token let decoded; try { @@ -358,10 +371,12 @@ class AuthController { } // Generate new access token + const role = await getUserRole(user.id); const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, + role, }); logger.info('Token refreshed successfully', { diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 54a7111..ffac0bd 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -3,36 +3,6 @@ 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 = { - '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 { @@ -42,7 +12,7 @@ declare global { id: string; // UUID email: string; authentikSub: string; - role?: AppRole; // populated when role is stored in DB / JWT + role?: string; // populated when role is stored in DB / JWT groups?: string[]; }; } @@ -122,6 +92,7 @@ export const authenticate = async ( email: decoded.email, authentikSub: decoded.authentikSub, groups: decoded.groups ?? [], + role: decoded.role, }; logger.debug('User authenticated successfully', { @@ -139,60 +110,6 @@ 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 => { - 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 diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index fdbc425..ad92553 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -48,6 +48,19 @@ const PERMISSION_ROLE_MIN: Record = { 'admin:access': 'admin', 'audit:read': 'admin', 'audit:export': 'admin', + 'members:read': 'mitglied', + 'members:write': 'kommandant', + 'vehicles:write': 'kommandant', + 'vehicles:status': 'gruppenfuehrer', + 'vehicles:delete': 'admin', + 'equipment:write': 'gruppenfuehrer', + 'equipment:delete': 'admin', + 'events:write': 'gruppenfuehrer', + 'events:categories': 'gruppenfuehrer', + 'atemschutz:write': 'gruppenfuehrer', + 'atemschutz:delete': 'kommandant', + 'bookings:write': 'gruppenfuehrer', + 'bookings:delete': 'admin', }; function hasPermission(role: AppRole, permission: string): boolean { @@ -103,7 +116,9 @@ export function requirePermission(permission: string) { return; } - const role = await getUserRole(req.user.id); + const role = (req.user as any).role + ? (req.user as any).role as AppRole + : await getUserRole(req.user.id); // Attach role to request for downstream use (e.g., bericht_text redaction) (req as Request & { userRole?: AppRole }).userRole = role; @@ -149,7 +164,9 @@ export function requireGroups(requiredGroups: string[]) { return; } - const userGroups: string[] = (req.user as any).groups ?? []; + logger.warn('DEPRECATED: requireGroups() — migrate to requirePermission()', { requiredGroups }); + + const userGroups: string[] = req.user?.groups ?? []; const hasAccess = requiredGroups.some(g => userGroups.includes(g)); if (!hasAccess) { diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index e8ec205..12fe777 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -1,10 +1,7 @@ import { Router } from 'express'; import atemschutzController from '../controllers/atemschutz.controller'; import { authenticate } from '../middleware/auth.middleware'; -import { requireGroups } from '../middleware/rbac.middleware'; - -const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); @@ -15,13 +12,13 @@ router.get('/stats', authenticate, atemschutzController.getStats.bind(atems router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController)); router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); -// ── Write — admin + kommandant ─────────────────────────────────────────────── +// ── Write — gruppenfuehrer+ ───────────────────────────────────────────────── -router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController)); -router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController)); +router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController)); +router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController)); -// ── Delete — admin only ────────────────────────────────────────────────────── +// ── Delete — kommandant+ ──────────────────────────────────────────────────── -router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController)); +router.delete('/:id', authenticate, requirePermission('atemschutz:delete'), atemschutzController.delete.bind(atemschutzController)); export default router; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 090e3cc..81faf54 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import authController from '../controllers/auth.controller'; -import { optionalAuth } from '../middleware/auth.middleware'; +import { authenticate } from '../middleware/auth.middleware'; const router = Router(); @@ -14,9 +14,9 @@ router.post('/callback', authController.handleCallback); /** * @route POST /api/auth/logout * @desc Logout user - * @access Public (optional auth for logging purposes) + * @access Private */ -router.post('/logout', optionalAuth, authController.handleLogout); +router.post('/logout', authenticate, authController.handleLogout); /** * @route POST /api/auth/refresh diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts index 0ac4fdc..9b70acc 100644 --- a/backend/src/routes/booking.routes.ts +++ b/backend/src/routes/booking.routes.ts @@ -1,10 +1,7 @@ import { Router } from 'express'; import bookingController from '../controllers/booking.controller'; import { authenticate, optionalAuth } from '../middleware/auth.middleware'; -import { requireGroups } from '../middleware/rbac.middleware'; - -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator']; -const ADMIN_GROUPS = ['dashboard_admin']; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); @@ -22,13 +19,13 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b // ── Write operations ────────────────────────────────────────────────────────── router.post('/', authenticate, bookingController.create.bind(bookingController)); -router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController)); +router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController)); // Soft-cancel (sets abgesagt=TRUE) -router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController)); +router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController)); // Hard-delete (admin only) -router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController)); +router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController)); // ── Single booking read — after specific routes to avoid path conflicts ─────── diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index b4ea86f..2f12775 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -1,10 +1,7 @@ import { Router } from 'express'; import equipmentController from '../controllers/equipment.controller'; import { authenticate } from '../middleware/auth.middleware'; -import { requireGroups } from '../middleware/rbac.middleware'; - -const ADMIN_GROUPS = ['dashboard_admin']; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister']; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); @@ -18,15 +15,15 @@ router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleW router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); -// ── Write — admin + fahrmeister ────────────────────────────────────────────── +// ── Write — gruppenfuehrer+ ──────────────────────────────────────────────── -router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController)); -router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController)); -router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController)); -router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController)); +router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController)); +router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController)); +router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController)); +router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController)); // ── Delete — admin only ────────────────────────────────────────────────────── -router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController)); +router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController)); export default router; diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index 9f9d26a..7bb44eb 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -1,13 +1,10 @@ import { Router } from 'express'; import eventsController from '../controllers/events.controller'; import { authenticate, optionalAuth } from '../middleware/auth.middleware'; -import { requireGroups } from '../middleware/rbac.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -/** Groups that may create, update, or cancel events */ -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator']; - // --------------------------------------------------------------------------- // Categories // --------------------------------------------------------------------------- @@ -20,34 +17,34 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve /** * POST /api/events/kategorien - * Create a new category. Requires admin or moderator. + * Create a new category. Requires gruppenfuehrer+. */ router.post( '/kategorien', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:categories'), eventsController.createKategorie.bind(eventsController) ); /** * PATCH /api/events/kategorien/:id - * Update an existing category. Requires admin or moderator. + * Update an existing category. Requires gruppenfuehrer+. */ router.patch( '/kategorien/:id', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:categories'), eventsController.updateKategorie.bind(eventsController) ); /** * DELETE /api/events/kategorien/:id - * Delete a category (only if no events reference it). Requires admin or moderator. + * Delete a category (only if no events reference it). Requires gruppenfuehrer+. */ router.delete( '/kategorien/:id', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:categories'), eventsController.deleteKategorie.bind(eventsController) ); @@ -106,23 +103,23 @@ router.get( /** * POST /api/events/import - * Bulk import events from CSV data. Requires admin or moderator. + * Bulk import events from CSV data. Requires gruppenfuehrer+. */ router.post( '/import', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:write'), eventsController.importEvents.bind(eventsController) ); /** * POST /api/events - * Create a new event. Requires admin or moderator. + * Create a new event. Requires gruppenfuehrer+. */ router.post( '/', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:write'), eventsController.createEvent.bind(eventsController) ); @@ -134,34 +131,34 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController) /** * PATCH /api/events/:id - * Update an existing event. Requires admin or moderator. + * Update an existing event. Requires gruppenfuehrer+. */ router.patch( '/:id', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:write'), eventsController.updateEvent.bind(eventsController) ); /** * DELETE /api/events/:id - * Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator. + * Soft-cancel an event (sets abgesagt=TRUE + reason). Requires gruppenfuehrer+. */ router.delete( '/:id', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:write'), eventsController.cancelEvent.bind(eventsController) ); /** * POST /api/events/:id/delete - * Hard-delete an event permanently. Requires admin or moderator. + * Hard-delete an event permanently. Requires gruppenfuehrer+. */ router.post( '/:id/delete', authenticate, - requireGroups(WRITE_GROUPS), + requirePermission('events:write'), eventsController.deleteEvent.bind(eventsController) ); diff --git a/backend/src/routes/member.routes.ts b/backend/src/routes/member.routes.ts index 7f62223..acd2045 100644 --- a/backend/src/routes/member.routes.ts +++ b/backend/src/routes/member.routes.ts @@ -1,87 +1,16 @@ import { Router, Request, Response, NextFunction } from 'express'; import memberController from '../controllers/member.controller'; import { authenticate } from '../middleware/auth.middleware'; -import logger from '../utils/logger'; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); // ---------------------------------------------------------------- -// Role/permission middleware -// -// The JWT currently carries: { userId, email, authentikSub }. -// Roles come from Authentik group membership stored in the -// `groups` array on the UserInfo response. The auth controller -// already upserts the user in the DB on every login; this -// middleware resolves the role from req.user (extended below). -// -// Until a full roles column exists in the users table, roles are -// derived from a well-known Authentik group naming convention: -// -// "feuerwehr-admin" → AppRole 'admin' -// "feuerwehr-kommandant" → AppRole 'kommandant' -// (everything else) → AppRole 'mitglied' -// -// The groups are passed through the JWT as req.user.groups (added -// by the extended type below). Replace this logic with a DB -// lookup once a roles column is added to users. +// Apply authentication to every route in this router. +// requirePermission is applied per-route because PATCH allows the +// owner to update their own limited fields even without 'members:write'. // ---------------------------------------------------------------- - -type AppRole = 'admin' | 'kommandant' | 'mitglied'; - -/** - * Resolves the AppRole from Authentik groups attached to the JWT. - * Mutates req.user.role so downstream controllers can read it directly. - */ -const resolveRole = (req: Request, _res: Response, next: NextFunction): void => { - if (req.user) { - const groups: string[] = (req.user as any).groups ?? []; - if (groups.includes('feuerwehr-admin')) { - req.user.role = 'admin'; - } else if (groups.includes('feuerwehr-kommandant')) { - req.user.role = 'kommandant' as any; - } else { - req.user.role = 'mitglied' as any; - } - logger.debug('resolveRole', { userId: req.user.id, role: req.user.role }); - } - next(); -}; - -/** - * Factory: creates a middleware that enforces the minimum required role. - * Role hierarchy: admin > kommandant > mitglied - */ -const requirePermission = (permission: 'members:read' | 'members:write') => { - return (req: Request, res: Response, next: NextFunction): void => { - const role = (req.user as any)?.role ?? 'mitglied'; - - const writeRoles: AppRole[] = ['admin', 'kommandant']; - const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied']; - - const allowed = - permission === 'members:write' - ? writeRoles.includes(role) - : readRoles.includes(role); - - if (!allowed) { - res.status(403).json({ - success: false, - message: 'Keine Berechtigung für diese Aktion.', - }); - return; - } - - next(); - }; -}; - -// ---------------------------------------------------------------- -// Apply authentication + role resolution to every route in this -// router. Note: requirePermission is applied per-route because -// PATCH allows the owner to update their own limited fields even -// without 'members:write'. -// ---------------------------------------------------------------- -router.use(authenticate, resolveRole); +router.use(authenticate); // IMPORTANT: The static /stats route must be registered BEFORE // the dynamic /:userId route, otherwise Express would match diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index be356ee..279d35b 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -1,10 +1,7 @@ import { Router } from 'express'; import vehicleController from '../controllers/vehicle.controller'; import { authenticate } from '../middleware/auth.middleware'; -import { requireGroups } from '../middleware/rbac.middleware'; - -const ADMIN_GROUPS = ['dashboard_admin']; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; +import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); @@ -16,15 +13,15 @@ router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleCont router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); -// ── Write — admin only ──────────────────────────────────────────────────────── +// ── Write — kommandant+ ────────────────────────────────────────────────────── -router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController)); -router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController)); -router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController)); +router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController)); +router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController)); +router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController)); -// ── Status + maintenance log — admin + fahrmeister ──────────────────────────── +// ── Status + maintenance log — gruppenfuehrer+ ────────────────────────────── -router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController)); -router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController)); +router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController)); +router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController)); export default router; diff --git a/backend/src/services/authentik.service.ts b/backend/src/services/authentik.service.ts index 56fc1ba..4495f12 100644 --- a/backend/src/services/authentik.service.ts +++ b/backend/src/services/authentik.service.ts @@ -1,9 +1,23 @@ import axios, { AxiosError } from 'axios'; +import * as jose from 'jose'; import authentikConfig from '../config/authentik'; import logger from '../utils/logger'; import { TokenResponse, UserInfo } from '../types/auth.types'; class AuthentikService { + private jwks: ReturnType | null = null; + + private getJwks(): ReturnType { + if (!this.jwks) { + // Derive JWKS URI from the issuer URL + // Issuer: https://auth.example.com/application/o/myapp/ + // JWKS: https://auth.example.com/application/o/myapp/jwks/ + const jwksUri = new URL('jwks/', authentikConfig.issuer.endsWith('/') ? authentikConfig.issuer : authentikConfig.issuer + '/'); + this.jwks = jose.createRemoteJWKSet(jwksUri); + } + return this.jwks; + } + /** * Exchange authorization code for access and ID tokens */ @@ -61,48 +75,26 @@ class AuthentikService { } /** - * Verify and decode ID token (basic validation) - * Note: For production, use a proper JWT verification library like jose or jsonwebtoken + * Verify and decode ID token using JWKS */ - verifyIdToken(idToken: string): any { + async verifyIdToken(idToken: string): Promise { try { - // Split the token into parts - const parts = idToken.split('.'); - if (parts.length !== 3) { - throw new Error('Invalid ID token format'); - } + const { payload } = await jose.jwtVerify(idToken, this.getJwks(), { + issuer: authentikConfig.issuer, + }); - // Decode the payload (Base64URL) - const payload = JSON.parse( - Buffer.from(parts[1], 'base64url').toString('utf-8') - ); - - // Basic validation if (!payload.sub || !payload.email) { - throw new Error('Invalid ID token payload'); + throw new Error('Invalid ID token payload: missing sub or email'); } - // Check expiration - if (payload.exp && payload.exp * 1000 < Date.now()) { - throw new Error('ID token has expired'); - } - - // Check issuer - if (payload.iss && !payload.iss.includes(authentikConfig.issuer)) { - logger.warn('ID token issuer mismatch', { - expected: authentikConfig.issuer, - received: payload.iss, - }); - } - - logger.info('ID token verified successfully', { + logger.info('ID token verified successfully via JWKS', { sub: payload.sub, email: payload.email, }); return payload; } catch (error) { - logger.error('Failed to verify ID token', { error }); + logger.error('Failed to verify ID token via JWKS', { error }); throw new Error('Invalid ID token'); } } diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index 89141ab..8aa4c33 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -15,6 +15,7 @@ class TokenService { email: payload.email, authentikSub: payload.authentikSub, groups: payload.groups ?? [], + role: payload.role, }, environment.jwt.secret, { diff --git a/backend/src/types/auth.types.ts b/backend/src/types/auth.types.ts index 51c8f4e..553579c 100644 --- a/backend/src/types/auth.types.ts +++ b/backend/src/types/auth.types.ts @@ -28,6 +28,7 @@ export interface JwtPayload { email: string; authentikSub: string; groups?: string[]; + role?: string; iat?: number; exp?: number; } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 7975710..628e78e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react'; import { AuthContextType, AuthState, User } from '../types/auth.types'; import { authService } from '../services/auth'; -import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage'; +import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage'; import { useNotification } from './NotificationContext'; import { setAuthInitialized } from '../services/api'; @@ -71,10 +71,11 @@ export const AuthProvider: React.FC = ({ children }) => { try { setState((prev) => ({ ...prev, isLoading: true })); - const { token, user } = await authService.handleCallback(code); + const { token, refreshToken, user } = await authService.handleCallback(code); // Save to localStorage setToken(token); + setRefreshToken(refreshToken); setUser(user); // Update state @@ -115,6 +116,7 @@ export const AuthProvider: React.FC = ({ children }) => { // Clear local state removeToken(); + removeRefreshToken(); removeUser(); setState({ user: null, diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1f3b484..5a7f131 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Container, Typography, @@ -10,35 +9,16 @@ import { Switch, Divider, Box, - Button, ToggleButtonGroup, ToggleButton, } from '@mui/material'; -import { Settings as SettingsIcon, Notifications, Palette, Language, Save, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material'; +import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; -import { useNotification } from '../contexts/NotificationContext'; import { useThemeMode } from '../contexts/ThemeContext'; function Settings() { - const notification = useNotification(); const { themeMode, setThemeMode } = useThemeMode(); - // Settings state - const [emailNotifications, setEmailNotifications] = useState(true); - const [alarmNotifications, setAlarmNotifications] = useState(true); - const [maintenanceReminders, setMaintenanceReminders] = useState(false); - const [systemNotifications, setSystemNotifications] = useState(true); - const [compactView, setCompactView] = useState(true); - const [animations, setAnimations] = useState(true); - - const handleSaveSettings = () => { - try { - // In a real application, save settings to backend - notification.showSuccess('Einstellungen erfolgreich gespeichert'); - } catch (error) { - notification.showError('Fehler beim Speichern der Einstellungen'); - } - }; return ( @@ -58,41 +38,24 @@ function Settings() { setEmailNotifications(e.target.checked)} - /> - } + control={} label="E-Mail-Benachrichtigungen" /> setAlarmNotifications(e.target.checked)} - /> - } + control={} label="Einsatz-Alarme" /> setMaintenanceReminders(e.target.checked)} - /> - } + control={} label="Wartungserinnerungen" /> setSystemNotifications(e.target.checked)} - /> - } + control={} label="System-Benachrichtigungen" /> + + (Bald verfügbar) + @@ -109,23 +72,16 @@ function Settings() { setCompactView(e.target.checked)} - /> - } + control={} label="Kompakte Ansicht" /> setAnimations(e.target.checked)} - /> - } + control={} label="Animationen" /> + + (Bald verfügbar) + Farbschema @@ -204,18 +160,6 @@ function Settings() { wird in zukünftigen Updates implementiert. - - - - ); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f91c745..45b98b4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,10 +1,27 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; import { API_URL } from '../utils/config'; -import { getToken, removeToken, removeUser } from '../utils/storage'; +import { getToken, setToken, removeToken, removeUser, getRefreshToken, removeRefreshToken } from '../utils/storage'; let authInitialized = false; let isRedirectingToLogin = false; +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: any) => void; +}> = []; + +function processQueue(error: any, token: string | null = null) { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token!); + } + }); + failedQueue = []; +} + export function setAuthInitialized(value: boolean): void { authInitialized = value; if (value === true) { @@ -54,15 +71,71 @@ class ApiService { (response) => response, async (error: AxiosError) => { if (error.response?.status === 401) { - if (authInitialized && !isRedirectingToLogin) { - isRedirectingToLogin = true; - // Clear tokens and redirect to login - console.warn('Unauthorized request, redirecting to login'); - removeToken(); - removeUser(); - window.location.href = '/login'; + const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + + // Don't attempt refresh during auth initialization or if already retried + if (!authInitialized || originalRequest._retry) { + if (authInitialized && !isRedirectingToLogin) { + isRedirectingToLogin = true; + removeToken(); + removeRefreshToken(); + removeUser(); + window.location.href = '/login'; + } + return Promise.reject(this.handleError(error)); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: (token: string) => { + originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${token}` }; + resolve(this.axiosInstance.request(originalRequest)); + }, + reject: (err: any) => { + reject(err); + }, + }); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = getRefreshToken(); + if (!refreshToken) { + isRefreshing = false; + if (!isRedirectingToLogin) { + isRedirectingToLogin = true; + removeToken(); + removeRefreshToken(); + removeUser(); + window.location.href = '/login'; + } + return Promise.reject(this.handleError(error)); + } + + try { + // Use a raw axios call (not the intercepted instance) to avoid loops + const response = await axios.post(`${API_URL}/api/auth/refresh`, { refreshToken }); + const newToken = response.data.data.accessToken; + setToken(newToken); + processQueue(null, newToken); + originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${newToken}` }; + return this.axiosInstance.request(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + if (!isRedirectingToLogin) { + isRedirectingToLogin = true; + removeToken(); + removeRefreshToken(); + removeUser(); + window.location.href = '/login'; + } + return Promise.reject(this.handleError(error)); + } finally { + isRefreshing = false; } - // During initialization, silently reject without redirecting } // Retry on 429 (Too Many Requests) with exponential backoff diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 6aa7e27..121596c 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -6,6 +6,7 @@ const REDIRECT_URI = `${window.location.origin}/auth/callback`; export interface AuthCallbackResponse { token: string; + refreshToken: string; user: User; } @@ -51,6 +52,7 @@ export const authService = { }); return { token: response.data.data.accessToken, + refreshToken: response.data.data.refreshToken, user: mapBackendUser(response.data.data.user), }; }, diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index d0deff7..0ad5f16 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -28,6 +28,33 @@ export const removeToken = (): void => { } }; +const REFRESH_TOKEN_KEY = 'auth_refresh_token'; + +export const getRefreshToken = (): string | null => { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch (error) { + console.error('Error getting refresh token from localStorage:', error); + return null; + } +}; + +export const setRefreshToken = (token: string): void => { + try { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + } catch (error) { + console.error('Error setting refresh token in localStorage:', error); + } +}; + +export const removeRefreshToken = (): void => { + try { + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch (error) { + console.error('Error removing refresh token from localStorage:', error); + } +}; + export const getUser = (): User | null => { try { const userStr = localStorage.getItem(USER_KEY);