import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import AdminForth from './index.js';
import { IAdminForthAuth } from './types/Back.js';
// Function to generate a password hash using PBKDF2
function calcPasswordHash(password, salt, iterations = 100000, keyLength = 64, digest = 'sha512') {
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
if (err) reject(err);
resolve(derivedKey.toString('hex'));
});
});
}
// Function to generate a random salt
function generateSalt(length = 16) {
return crypto.randomBytes(length).toString('hex');
}
function parseTimeToSeconds(time: string): number {
const unit = time.slice(-1);
const value = parseInt(time.slice(0, -1), 10);
switch (unit) {
case 's':
return value;
case 'm':
return value * 60;
case 'h':
return value * 60 * 60;
case 'd':
return value * 60 * 60 * 24;
default:
throw new Error(`Invalid time unit: ${unit}`);
}
}
class AdminForthAuth implements IAdminForthAuth {
adminforth: AdminForth;
constructor(adminforth) {
this.adminforth = adminforth;
}
getClientIp(headers: object) {
const clientIpHeader = this.adminforth.config.auth.clientIpHeader;
const headersLower = Object.keys(headers).reduce((acc, key) => {
acc[key.toLowerCase()] = headers[key];
return acc;
}, {});
if (clientIpHeader) {
return headersLower[clientIpHeader.toLowerCase()] || 'unknown';
} else {
// first try common headers which can't bee spoofed, in other words
// most common to nginx/traefik/apache
// then fallback to less secure headers
return headersLower['x-forwarded-for']?.split(',').shift().trim() ||
headersLower['x-real-ip'] ||
headersLower['x-client-ip'] ||
headersLower['x-cluster-client-ip'] ||
headersLower['forwarded'] ||
headersLower['remote-addr'] ||
headersLower['client-ip'] ||
headersLower['client-address'] ||
headersLower['client'] ||
headersLower['x-host'] ||
headersLower['host'] ||
'unknown';
}
}
removeAuthCookie(response) {
const brandSlug = this.adminforth.config.customization.brandNameSlug;
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_jwt=; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
}
setAuthCookie({ expireInDays, response, username, pk}: {
expireInDays?: number,
response: any,
username: string,
pk: string | null
}) {
const expiresIn: string = expireInDays ? `${expireInDays}d` : (process.env.ADMINFORTH_AUTH_EXPIRESIN || '24h');
// might be h,m,d in string
const expiresInSec = parseTimeToSeconds(expiresIn);
const token = this.issueJWT({ username, pk}, 'auth', expiresIn);
const expiresCookieFormat = new Date(Date.now() + expiresInSec * 1000).toUTCString();
const brandSlug = this.adminforth.config.customization.brandNameSlug;
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_jwt=${token}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${expiresCookieFormat}`);
}
removeCustomCookie({response, name}) {
const brandSlug = this.adminforth.config.customization.brandNameSlug;
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_${name}=; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
}
setCustomCookie({ response, payload }: {
response: any, payload: {name: string, value: string, expiry: number, httpOnly: boolean}
}) {
const {name, value, expiry, httpOnly} = payload;
const brandSlug = this.adminforth.config.customization.brandNameSlug;
response.setHeader('Set-Cookie', `adminforth_${brandSlug}_${name}=${value}; Path=${this.adminforth.config.baseUrl || '/'};${
httpOnly ? ' HttpOnly;' : ''
} SameSite=Strict; Expires=${new Date(Date.now() + expiry).toUTCString() } `);
}
issueJWT(payload: Object, type: string, expiresIn: string = '24h'): string {
// read ADMINFORH_SECRET from environment if not drop error
const secret = process.env.ADMINFORTH_SECRET;
if (!secret) {
throw new Error('ADMINFORTH_SECRET environment not set');
}
// issue JWT token
return jwt.sign({...payload, t: type}, secret, { expiresIn });
}
async verify(jwtToken: string, mustHaveType: string, decodeUser: boolean | undefined = true): Promise {
// read ADMINFORH_SECRET from environment if not drop error
const secret = process.env.ADMINFORTH_SECRET;
if (!secret) {
throw new Error('ADMINFORTH_SECRET environment not set');
}
let decoded;
try {
// verify JWT token
decoded = jwt.verify(jwtToken, secret);
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.error('Token expired:', err.message);
} else if (err.name === 'JsonWebTokenError') {
console.error('Token error:', err.message);
} else {
console.error('Failed to verify JWT token', err);
}
return null;
}
const { pk, t } = decoded;
if (t !== mustHaveType) {
console.error(`Invalid token type during verification: ${t}, must be ${mustHaveType}`);
return null;
}
if (decodeUser !== false) {
const dbUser = await this.adminforth.getUserByPk(pk);
if (!dbUser) {
console.error(`User with pk ${pk} not found in database`);
// will logout user which was deleted
return null;
}
decoded.dbUser = dbUser;
}
return decoded;
}
static async generatePasswordHash(password) {
const salt = generateSalt();
const hashedPassword = await calcPasswordHash(password, salt);
return `${salt}:${hashedPassword}`;
}
static async verifyPassword(password, hashedPassword) {
const [salt, hash] = hashedPassword.split(':');
const newHash = await calcPasswordHash(password, salt);
return newHash === hash;
}
}
export default AdminForthAuth;