Files
dashboard/backend/src/controllers/auth.controller.ts
Matthias Hochmeister 550a5a4883 update
2026-03-16 14:42:39 +01:00

417 lines
13 KiB
TypeScript

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 memberService from '../services/member.service';
import logger from '../utils/logger';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
import { getUserRole } from '../middleware/rbac.middleware';
import pool from '../config/database';
/**
* 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
// BUT: guard against the case where given_name is actually the full name
// (e.g. Authentik sends given_name="Matthias Hochmeister", family_name="Hochmeister")
if (givenName && familyName && givenName !== familyName) {
const looksLikeFullName =
givenName.includes(' ') &&
(givenName.endsWith(' ' + familyName) || givenName === familyName);
if (!looksLikeFullName) {
return { given_name: givenName, family_name: familyName };
}
// Fall through to split the name field
}
// 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 callbackSchema = z.object({
code: z.string().min(1),
redirect_uri: z.string().url().optional(),
});
const parseResult = callbackSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Authorization code is required',
});
return;
}
const { code } = parseResult.data;
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 {
await authentikService.verifyIdToken(tokens.id_token);
} catch (error) {
logger.error('ID token verification failed — continuing with userinfo (security event)', { 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);
await memberService.ensureProfileExists(user.id);
// 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 — 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,
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
// 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,
profile_picture_url: userInfo.picture || undefined,
});
// 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);
// 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
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 refreshSchema = z.object({
refreshToken: z.string().min(1),
});
const parseResult = refreshSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Refresh token is required',
});
return;
}
const { refreshToken } = parseResult.data;
// 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 role = await getUserRole(user.id);
// Fetch groups from DB so refreshed tokens retain group info
const groupsResult = await pool.query(
'SELECT authentik_groups FROM users WHERE id = $1',
[user.id]
);
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
const accessToken = tokenService.generateToken({
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
groups,
role,
});
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();