Files
dashboard/backend/src/controllers/auth.controller.ts
Matthias Hochmeister 2306741c4d feat: dashboard widgets, auth fix, profile names, dynamic groups
- Add VehicleDashboardCard: self-contained widget modelled after
  AtemschutzDashboardCard, shows einsatzbereit ratio and inspection
  warnings inline; replaces StatsCard + InspectionAlerts in Dashboard

- Add EquipmentDashboardCard: consolidated equipment status widget
  showing only aggregated counts (no per-item listing); replaces
  EquipmentAlerts component in Dashboard

- Fix auth race condition: add authInitialized flag to api.ts so 401
  responses during initial token validation no longer trigger a
  spurious redirect to /login; save intended destination before login
  redirect and restore it after successful auth callback

- Fix profile firstname/lastname: add extractNames() helper to
  auth.controller.ts that falls back to splitting userinfo.name when
  Authentik does not provide separate given_name/family_name fields;
  applied on both create and update paths

- Dynamic groups endpoint: replace hardcoded KNOWN_GROUPS array in
  events.controller.ts with a DB query (SELECT DISTINCT unnest
  (authentik_groups) FROM users); known slugs get German labels via
  lookup map, unknown slugs are humanized automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:28:31 +01:00

383 lines
11 KiB
TypeScript

import { Request, Response } from 'express';
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';
/**
* Extract given_name and family_name from Authentik userinfo.
* Falls back to splitting the `name` field if individual fields are missing or identical.
*/
function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): {
given_name: string | undefined;
family_name: string | undefined;
} {
const givenName = userInfo.given_name?.trim();
const familyName = userInfo.family_name?.trim();
// If Authentik provides both and they differ, use them directly
if (givenName && familyName && givenName !== familyName) {
return { given_name: givenName, family_name: familyName };
}
// Fall back to splitting the name field
if (userInfo.name) {
const parts = userInfo.name.trim().split(/\s+/);
if (parts.length >= 2) {
return {
given_name: parts[0],
family_name: parts.slice(1).join(' '),
};
}
// Single word name — use as given_name only
return {
given_name: parts[0],
family_name: undefined,
};
}
return { given_name: givenName, family_name: familyName };
}
class AuthController {
/**
* Handle OAuth callback
* 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;
// Validate code
if (!code) {
res.status(400).json({
success: false,
message: 'Authorization code is required',
});
return;
}
logger.info('Processing OAuth callback', { hasCode: !!code });
// Step 1: Exchange code for tokens
const tokens = await authentikService.exchangeCodeForTokens(code);
// Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
// Step 3: Verify ID token if present
if (tokens.id_token) {
try {
authentikService.verifyIdToken(tokens.id_token);
} catch (error) {
logger.warn('ID token verification failed', { error });
}
}
// Step 4: Find or create user in database
let user = await userService.findByAuthentikSub(userInfo.sub);
if (!user) {
// User doesn't exist, create new user
logger.info('Creating new user from Authentik', {
sub: userInfo.sub,
email: userInfo.email,
});
const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo);
user = await userService.createUser({
email: userInfo.email,
authentik_sub: userInfo.sub,
preferred_username: userInfo.preferred_username,
given_name: newGivenName,
family_name: newFamilyName,
name: userInfo.name,
profile_picture_url: userInfo.picture,
});
await userService.updateGroups(user.id, groups);
// 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,
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
// Refresh profile fields from Authentik on every login
await userService.updateUser(user.id, {
name: userInfo.name,
given_name: updatedGivenName,
family_name: updatedFamilyName,
preferred_username: userInfo.preferred_username,
});
// 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: {},
});
}
// 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 accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
groups,
});
// Generate refresh token
const refreshToken = tokenService.generateRefreshToken({
userId: user.id,
email: user.email,
});
logger.info('User authenticated successfully', {
userId: user.id,
email: user.email,
});
// Step 6: Return tokens and user info
res.status(200).json({
success: true,
message: 'Authentication successful',
data: {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: userInfo.name || user.name,
preferredUsername: userInfo.preferred_username || user.preferred_username,
givenName: resolvedGivenName || user.given_name,
familyName: resolvedFamilyName || user.family_name,
profilePictureUrl: user.profile_picture_url,
isActive: user.is_active,
groups,
},
},
});
} 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';
res.status(500).json({
success: false,
message,
});
}
}
/**
* Handle logout
* POST /api/auth/logout
*/
async handleLogout(req: Request, res: Response): Promise<void> {
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,
});
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: {},
});
}
res.status(200).json({
success: true,
message: 'Logout successful',
});
} catch (error) {
logger.error('Logout error', { error });
res.status(500).json({
success: false,
message: 'Logout failed',
});
}
}
/**
* Handle token refresh
* POST /api/auth/refresh
*/
async handleRefresh(req: Request, res: Response): Promise<void> {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({
success: false,
message: 'Refresh token is required',
});
return;
}
// Verify refresh token
let decoded;
try {
decoded = tokenService.verifyRefreshToken(refreshToken);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid refresh token';
res.status(401).json({
success: false,
message,
});
return;
}
// Get user from database
const user = await userService.findById(decoded.userId);
if (!user) {
logger.warn('Refresh token valid but user not found', {
userId: decoded.userId,
});
res.status(401).json({
success: false,
message: 'User not found',
});
return;
}
if (!user.is_active) {
logger.warn('Inactive user attempted token refresh', {
userId: user.id,
});
res.status(403).json({
success: false,
message: 'User account is inactive',
});
return;
}
// Generate new access token
const accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
});
logger.info('Token refreshed successfully', {
userId: user.id,
email: user.email,
});
res.status(200).json({
success: true,
message: 'Token refreshed successfully',
data: {
accessToken,
},
});
} catch (error) {
logger.error('Token refresh error', { error });
res.status(500).json({
success: false,
message: 'Token refresh failed',
});
}
}
}
export default new AuthController();