- 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>
383 lines
11 KiB
TypeScript
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();
|