inital
This commit is contained in:
158
backend/src/services/authentik.service.ts
Normal file
158
backend/src/services/authentik.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import authentikConfig from '../config/authentik';
|
||||
import logger from '../utils/logger';
|
||||
import { TokenResponse, UserInfo } from '../types/auth.types';
|
||||
|
||||
class AuthentikService {
|
||||
/**
|
||||
* Exchange authorization code for access and ID tokens
|
||||
*/
|
||||
async exchangeCodeForTokens(code: string): Promise<TokenResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: authentikConfig.redirectUri,
|
||||
client_id: authentikConfig.clientId,
|
||||
client_secret: authentikConfig.clientSecret,
|
||||
});
|
||||
|
||||
const response = await axios.post<TokenResponse>(
|
||||
authentikConfig.tokenEndpoint,
|
||||
params.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Successfully exchanged code for tokens');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Failed to exchange code for tokens');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user information from Authentik using access token
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<UserInfo> {
|
||||
try {
|
||||
const response = await axios.get<UserInfo>(
|
||||
authentikConfig.userInfoEndpoint,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Successfully fetched user info', {
|
||||
sub: response.data.sub,
|
||||
email: response.data.email,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Failed to fetch user info');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode ID token (basic validation)
|
||||
* Note: For production, use a proper JWT verification library like jose or jsonwebtoken
|
||||
*/
|
||||
verifyIdToken(idToken: string): any {
|
||||
try {
|
||||
// Split the token into parts
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid ID token format');
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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', {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
});
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify ID token', { error });
|
||||
throw new Error('Invalid ID token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: authentikConfig.clientId,
|
||||
client_secret: authentikConfig.clientSecret,
|
||||
});
|
||||
|
||||
const response = await axios.post<TokenResponse>(
|
||||
authentikConfig.tokenEndpoint,
|
||||
params.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Successfully refreshed access token');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Failed to refresh access token');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle axios errors with detailed logging
|
||||
*/
|
||||
private handleError(error: unknown, message: string): void {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
logger.error(message, {
|
||||
status: axiosError.response?.status,
|
||||
statusText: axiosError.response?.statusText,
|
||||
data: axiosError.response?.data,
|
||||
message: axiosError.message,
|
||||
});
|
||||
} else {
|
||||
logger.error(message, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuthentikService();
|
||||
122
backend/src/services/token.service.ts
Normal file
122
backend/src/services/token.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import environment from '../config/environment';
|
||||
import logger from '../utils/logger';
|
||||
import { JwtPayload, RefreshTokenPayload } from '../types/auth.types';
|
||||
|
||||
class TokenService {
|
||||
/**
|
||||
* Generate JWT access token
|
||||
*/
|
||||
generateToken(payload: JwtPayload): string {
|
||||
try {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
authentikSub: payload.authentikSub,
|
||||
},
|
||||
environment.jwt.secret,
|
||||
{
|
||||
expiresIn: environment.jwt.expiresIn as any,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Generated JWT token', { userId: payload.userId });
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate JWT token', { error });
|
||||
throw new Error('Token generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode JWT token
|
||||
*/
|
||||
verifyToken(token: string): JwtPayload {
|
||||
try {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
environment.jwt.secret
|
||||
) as JwtPayload;
|
||||
|
||||
logger.debug('JWT token verified', { userId: decoded.userId });
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
logger.warn('JWT token expired');
|
||||
throw new Error('Token expired');
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
logger.warn('Invalid JWT token', { error: error.message });
|
||||
throw new Error('Invalid token');
|
||||
} else {
|
||||
logger.error('Failed to verify JWT token', { error });
|
||||
throw new Error('Token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (longer lived)
|
||||
*/
|
||||
generateRefreshToken(payload: RefreshTokenPayload): string {
|
||||
try {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
},
|
||||
environment.jwt.secret,
|
||||
{
|
||||
expiresIn: '7d', // Refresh tokens valid for 7 days
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Generated refresh token', { userId: payload.userId });
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate refresh token', { error });
|
||||
throw new Error('Refresh token generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify refresh token
|
||||
*/
|
||||
verifyRefreshToken(token: string): RefreshTokenPayload {
|
||||
try {
|
||||
const decoded = jwt.verify(
|
||||
token,
|
||||
environment.jwt.secret
|
||||
) as RefreshTokenPayload;
|
||||
|
||||
logger.debug('Refresh token verified', { userId: decoded.userId });
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
logger.warn('Refresh token expired');
|
||||
throw new Error('Refresh token expired');
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
logger.warn('Invalid refresh token', { error: error.message });
|
||||
throw new Error('Invalid refresh token');
|
||||
} else {
|
||||
logger.error('Failed to verify refresh token', { error });
|
||||
throw new Error('Refresh token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode token without verification (for debugging)
|
||||
*/
|
||||
decodeToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as JwtPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
logger.error('Failed to decode token', { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TokenService();
|
||||
275
backend/src/services/user.service.ts
Normal file
275
backend/src/services/user.service.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
import { User, CreateUserData, UpdateUserData } from '../models/user.model';
|
||||
|
||||
class UserService {
|
||||
/**
|
||||
* Find user by Authentik sub (subject identifier)
|
||||
*/
|
||||
async findByAuthentikSub(sub: string): Promise<User | null> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||
is_active, last_login_at, created_at, updated_at, preferences
|
||||
FROM users
|
||||
WHERE authentik_sub = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [sub]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.debug('User not found by Authentik sub', { sub });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('User found by Authentik sub', { sub, userId: result.rows[0].id });
|
||||
return result.rows[0] as User;
|
||||
} catch (error) {
|
||||
logger.error('Error finding user by Authentik sub', { error, sub });
|
||||
throw new Error('Database query failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||
is_active, last_login_at, created_at, updated_at, preferences
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [email]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.debug('User not found by email', { email });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('User found by email', { email, userId: result.rows[0].id });
|
||||
return result.rows[0] as User;
|
||||
} catch (error) {
|
||||
logger.error('Error finding user by email', { error, email });
|
||||
throw new Error('Database query failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by ID
|
||||
*/
|
||||
async findById(id: string): Promise<User | null> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT id, email, authentik_sub, name, preferred_username, given_name,
|
||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||
is_active, last_login_at, created_at, updated_at, preferences
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.debug('User not found by ID', { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('User found by ID', { id });
|
||||
return result.rows[0] as User;
|
||||
} catch (error) {
|
||||
logger.error('Error finding user by ID', { error, id });
|
||||
throw new Error('Database query failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async createUser(userData: CreateUserData): Promise<User> {
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO users (
|
||||
email,
|
||||
authentik_sub,
|
||||
name,
|
||||
preferred_username,
|
||||
given_name,
|
||||
family_name,
|
||||
profile_picture_url,
|
||||
is_active
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||
is_active, last_login_at, created_at, updated_at, preferences
|
||||
`;
|
||||
|
||||
const values = [
|
||||
userData.email,
|
||||
userData.authentik_sub,
|
||||
userData.name || null,
|
||||
userData.preferred_username || null,
|
||||
userData.given_name || null,
|
||||
userData.family_name || null,
|
||||
userData.profile_picture_url || null,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
const user = result.rows[0] as User;
|
||||
logger.info('User created successfully', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
logger.error('Error creating user', { error, email: userData.email });
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user information
|
||||
*/
|
||||
async updateUser(id: string, data: UpdateUserData): Promise<User> {
|
||||
try {
|
||||
const updateFields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updateFields.push(`name = $${paramCount++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.preferred_username !== undefined) {
|
||||
updateFields.push(`preferred_username = $${paramCount++}`);
|
||||
values.push(data.preferred_username);
|
||||
}
|
||||
if (data.given_name !== undefined) {
|
||||
updateFields.push(`given_name = $${paramCount++}`);
|
||||
values.push(data.given_name);
|
||||
}
|
||||
if (data.family_name !== undefined) {
|
||||
updateFields.push(`family_name = $${paramCount++}`);
|
||||
values.push(data.family_name);
|
||||
}
|
||||
if (data.profile_picture_url !== undefined) {
|
||||
updateFields.push(`profile_picture_url = $${paramCount++}`);
|
||||
values.push(data.profile_picture_url);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updateFields.push(`is_active = $${paramCount++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
if (data.preferences !== undefined) {
|
||||
updateFields.push(`preferences = $${paramCount++}`);
|
||||
values.push(JSON.stringify(data.preferences));
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
values.push(id);
|
||||
|
||||
const query = `
|
||||
UPDATE users
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING id, email, authentik_sub, name, preferred_username, given_name,
|
||||
family_name, profile_picture_url, refresh_token, refresh_token_expires_at,
|
||||
is_active, last_login_at, created_at, updated_at, preferences
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const user = result.rows[0] as User;
|
||||
logger.info('User updated successfully', { userId: user.id });
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
logger.error('Error updating user', { error, userId: id });
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login timestamp
|
||||
*/
|
||||
async updateLastLogin(id: string): Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE users
|
||||
SET last_login_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
await pool.query(query, [id]);
|
||||
logger.debug('Updated last login timestamp', { userId: id });
|
||||
} catch (error) {
|
||||
logger.error('Error updating last login', { error, userId: id });
|
||||
// Don't throw - this is not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update refresh token
|
||||
*/
|
||||
async updateRefreshToken(
|
||||
id: string,
|
||||
refreshToken: string | null,
|
||||
expiresAt: Date | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE users
|
||||
SET refresh_token = $1,
|
||||
refresh_token_expires_at = $2
|
||||
WHERE id = $3
|
||||
`;
|
||||
|
||||
await pool.query(query, [refreshToken, expiresAt, id]);
|
||||
logger.debug('Updated refresh token', { userId: id });
|
||||
} catch (error) {
|
||||
logger.error('Error updating refresh token', { error, userId: id });
|
||||
throw new Error('Failed to update refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is active
|
||||
*/
|
||||
async isUserActive(id: string): Promise<boolean> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT is_active
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.rows[0].is_active;
|
||||
} catch (error) {
|
||||
logger.error('Error checking user active status', { error, userId: id });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new UserService();
|
||||
Reference in New Issue
Block a user