417 lines
13 KiB
TypeScript
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();
|