Skip to content
Merged
59 changes: 57 additions & 2 deletions adminforth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import AdminForth from './index.js';
import { IAdminForthAuth } from './types/Back.js';
import { AdminUserAuthorizationResult, AdminUserAuthorizeFunction, HttpExtra, IAdminForthAuth, IAdminForthHttpResponse } from './types/Back.js';
import { AdminUser } from './types/Common.js';
import { listify } from './modules/utils.js';
import { afLogger } from './modules/logger.js';
import is_ip_private from 'private-ip'

Expand Down Expand Up @@ -133,7 +135,60 @@ class AdminForthAuth implements IAdminForthAuth {
const brandSlug = this.adminforth.config.customization.brandNameSlug;
return cookies.find((cookie) => cookie.key === `adminforth_${brandSlug}_${name}`)?.value || null;
}


getAuthCookie(cookies: {key: string, value: string}[]): string | null {
const brandSlug = this.adminforth.config.customization.brandNameSlug;
const jwts = cookies.filter(({ key }) => key === `adminforth_${brandSlug}_jwt`);
if (jwts.length > 1) {
afLogger.error('Multiple adminforth_jwt cookies provided');
}
return jwts[0]?.value || null;
}

async runAdminUserAuthorizeHooks(adminUser: AdminUser, response: IAdminForthHttpResponse, extra: HttpExtra): Promise<{ allowed: boolean, error?: string }> {
const adminUserAuthorize = this.adminforth.config.auth.adminUserAuthorize as (AdminUserAuthorizeFunction[] | undefined);

for (const hook of listify(adminUserAuthorize)) {
const resp = await hook({
adminUser,
response,
adminforth: this.adminforth,
extra,
});
if (resp?.allowed === false || resp?.error) {
return { allowed: resp?.allowed, error: resp?.error };
}
}
return { allowed: true };
}

async authorizeByCookies({ cookies, response, extra }: {
cookies: {key: string, value: string}[],
response: IAdminForthHttpResponse,
extra: HttpExtra,
}): Promise<AdminUserAuthorizationResult> {
const jwt = this.getAuthCookie(cookies);
if (!jwt) {
return { status: 'noToken' };
}

let adminUser: AdminUser | null;
try {
adminUser = await this.verify(jwt, 'auth') as AdminUser | null;
} catch (error) {
return { status: 'verifyFailed', error };
}
if (!adminUser) {
return { status: 'invalidToken' };
}

const { allowed, error } = await this.runAdminUserAuthorizeHooks(adminUser, response, extra);
if (!allowed) {
return { status: 'notAllowed', error };
}
return { status: 'ok', adminUser };
}

issueJWT(payload: Object, type: string, expiresIn: string | number = '24h'): string {
// read ADMINFORH_SECRET from environment if not drop error
const secret = process.env.ADMINFORTH_SECRET;
Expand Down
91 changes: 42 additions & 49 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AllowedActions,
AfterDataSourceResponseFunction,
BeforeDataSourceRequestFunction,
IAdminForthEndpointHandlerInput,
IAdminForthRestAPI,
IAdminForthSort,
HttpExtra,
Expand All @@ -27,7 +28,7 @@ import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, Adm
AdminForthSortDirections,
AdminUser, AllowedActionsEnum, AllowedActionsResolved,
AnnouncementBadgeResponse,
GetBaseConfigResponse,
GetConfigResponse,
ShowInResolved} from "../types/Common.js";
import { filtersTools } from "../modules/filtersTools.js";
import is_ip_private from 'private-ip'
Expand Down Expand Up @@ -671,6 +672,20 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
constructor(adminforth: IAdminForth) {
this.adminforth = adminforth;
}
private async getAdminUserFromRequest(
{ body, query, headers, cookies, requestUrl, response }: Pick<IAdminForthEndpointHandlerInput, 'body' | 'query' | 'headers' | 'cookies' | 'requestUrl' | 'response'>
): Promise<AdminUser | null> {
const result = await this.adminforth.auth.authorizeByCookies({
cookies,
response,
extra: { body, query, headers, cookies, requestUrl, meta: {}, response },
});

if (result.status === 'verifyFailed') {
throw result.error;
}
return result.status === 'ok' ? result.adminUser : null;
}

private normalizeJsonColumns(resource: AdminForthResource, record: any): string | null {
for (const column of resource.columns) {
Expand Down Expand Up @@ -843,20 +858,27 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
server.endpoint({
noAuth: true,
method: 'GET',
path: '/get_public_config',
handler: async ({ tr }) => {

// TODO we need to remove this method and make get_config to return public and private parts for logged in user and only public for not logged in
path: '/get_config',
handler: async ({ body, query, headers, cookies, requestUrl, tr, response }): Promise<GetConfigResponse>=> {
let username = ''
let userFullName = ''

// find resource
if (!this.adminforth.config.auth) {
throw new Error('No config.auth defined');
}

response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
response.setHeader('Pragma', 'no-cache');
response.setHeader('Expires', '0');
response.setHeader('Surrogate-Control', 'no-store');

const userResource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId);

const usernameField = this.adminforth.config.auth.usernameField;
const resource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId);
const usernameColumn = resource.columns.find((col) => col.name === usernameField);
const usernameColumn = userResource.columns.find((col) => col.name === usernameField);

return {
const public_config = {
brandName: this.adminforth.config.customization.brandName,
usernameFieldName: usernameColumn.label,
loginBackgroundImage: this.adminforth.config.auth.loginBackgroundImage,
Expand All @@ -867,39 +889,23 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
loginPageInjections: this.adminforth.config.customization.loginPageInjections,
globalInjections: {
everyPageBottom: this.adminforth.config.customization.globalInjections.everyPageBottom,
sidebarTop: this.adminforth.config.customization.globalInjections.sidebarTop,
},
rememberMeDuration: this.adminforth.config.auth.rememberMeDuration,
singleTheme: this.adminforth.config.customization.singleTheme,
customHeadItems: this.adminforth.config.customization.customHeadItems,
};
},
});

server.endpoint({
method: 'GET',
path: '/get_base_config',
handler: async ({ adminUser, cookies, tr, response }): Promise<GetBaseConfigResponse>=> {
let username = ''
let userFullName = ''

// find resource
if (!this.adminforth.config.auth) {
throw new Error('No config.auth defined');
}
// this endpoint is noAuth (login page needs public config), so here we repeat authorize flow
// of express server to understand whether caller is allowed to get base config as well
const adminUser = await this.getAdminUserFromRequest({ body, query, headers, cookies, requestUrl, response });

response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
response.setHeader('Pragma', 'no-cache');
response.setHeader('Expires', '0');
response.setHeader('Surrogate-Control', 'no-store');
if (!adminUser) {
return { loggedIn: false, config: public_config };
}

const dbUser = adminUser.dbUser;
username = dbUser[this.adminforth.config.auth.usernameField];
username = dbUser[this.adminforth.config.auth.usernameField];
userFullName = dbUser[this.adminforth.config.auth.userFullNameField];
const userResource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId);

const usernameField = this.adminforth.config.auth.usernameField;
const usernameColumn = userResource.columns.find((col) => col.name === usernameField);

const userPk = dbUser[userResource.columns.find((col) => col.primaryKey).name];

Expand Down Expand Up @@ -958,20 +964,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
const usersResource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId);
const defaultUserExists = await this.adminforth.resource(usersResource.resourceId).get(Filters.EQ(usernameField, 'adminforth')) ? true : false;


const publicPart = {
brandName: this.adminforth.config.customization.brandName,
usernameFieldName: usernameColumn.label,
loginBackgroundImage: this.adminforth.config.auth.loginBackgroundImage,
loginBackgroundPosition: this.adminforth.config.auth.loginBackgroundPosition,
removeBackgroundBlendMode: this.adminforth.config.auth.removeBackgroundBlendMode,
title: this.adminforth.config.customization?.title,
demoCredentials: this.adminforth.config.auth.demoCredentials,
loginPageInjections: this.adminforth.config.customization.loginPageInjections,
rememberMeDuration: this.adminforth.config.auth.rememberMeDuration,
singleTheme: this.adminforth.config.customization.singleTheme,
customHeadItems: this.adminforth.config.customization.customHeadItems,
}
const loggedInPart = {
showBrandNameInSidebar: this.adminforth.config.customization.showBrandNameInSidebar,
showBrandLogoInSidebar: this.adminforth.config.customization.showBrandLogoInSidebar,
Expand Down Expand Up @@ -1048,16 +1040,17 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
}

return {
loggedIn: true,
config: {
...public_config,
...loggedInPart,
},
user: userData,
resources: this.adminforth.config.resources.map((res) => ({
resourceId: res.resourceId,
label: res.label,
})),
menu: newMenu,
config: {
...publicPart,
...loggedInPart,
},
adminUser,
version: ADMINFORTH_VERSION,
};
Expand Down
82 changes: 22 additions & 60 deletions adminforth/servers/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { AnySchemaObject } from 'ajv';
import { apiReference } from '@scalar/express-api-reference';
import fetch from 'node-fetch';
import {
AdminUserAuthorizeFunction,
IAdminForth,
IAdminForthAuthenticatedEndpointOptions,
IAdminForthEndpointOptions,
Expand All @@ -21,7 +20,6 @@ import { AdminUser } from '../types/Common.js';
import http from 'http';
import type { AddressInfo } from 'net';
import { randomUUID } from 'crypto';
import { listify } from '../modules/utils.js';
import { afLogger } from '../modules/logger.js';
import { ADMINFORTH_CLIENT_ID_HEADER, runWithRequestContext } from '../modules/requestContext.js';
import * as z from 'zod';
Expand Down Expand Up @@ -290,12 +288,9 @@ class ExpressServer implements IExpressHttpServer {
let adminUser: AdminUser | null = null;
const cookies = req.headers.cookie;
if (cookies) {
const parsedCookies = parseCookiesString(cookies);
// find adminforth_jwt
const brandSlug = this.adminforth.config.customization.brandNameSlug;
const jwt = parsedCookies.find(({key}) => key === `adminforth_${brandSlug}_jwt`);
const jwt = this.adminforth.auth.getAuthCookie(parseCookiesString(cookies));
if (jwt) {
adminUser = await this.adminforth.auth.verify(jwt.value, 'auth');
adminUser = await this.adminforth.auth.verify(jwt, 'auth');
}
}

Expand Down Expand Up @@ -357,24 +352,6 @@ class ExpressServer implements IExpressHttpServer {
return `http://127.0.0.1:${(address as AddressInfo).port}`;
}

async processAuthorizeCallbacks(adminUser: AdminUser, toReturn: { error?: string, allowed: boolean }, response: Response, extra: HttpExtra) {
const adminUserAuthorize = this.adminforth.config.auth.adminUserAuthorize as (AdminUserAuthorizeFunction[] | undefined);

for (const hook of listify(adminUserAuthorize)) {
const resp = await hook({
adminUser,
response,
adminforth: this.adminforth,
extra,
});
if (resp?.allowed === false || resp?.error) {
// delete all items from toReturn and add these:
toReturn.allowed = resp?.allowed;
toReturn.error = resp?.error;
break;
}
}
}

runInRequestContext(req, callback) {
return runWithRequestContext({
Expand All @@ -387,50 +364,35 @@ class ExpressServer implements IExpressHttpServer {
return async (req, res, next) => {
return this.runInRequestContext(req, async () => {
const cookies = await parseExpressCookie(req);
const brandSlug = this.adminforth.config.customization.brandNameSlug;
// check if multiple adminforth_jwt providerd and show warning
const jwts = cookies.filter(({key}) => key === `adminforth_${brandSlug}_jwt`);
if (jwts.length > 1) {
afLogger.error('Multiple adminforth_jwt cookies provided');
}

const jwt = jwts[0]?.value;
const result = await this.adminforth.auth.authorizeByCookies({
cookies,
response: res,
extra: {
body: req.body,
query: req.query,
headers: req.headers,
cookies: cookies as any,
requestUrl: req.url,
meta: {},
response: res
},
});

if (!jwt) {
res.status(401).send('Unauthorized by AdminForth');
return
}
let adminforthUser;
try {
adminforthUser = await this.adminforth.auth.verify(jwt, 'auth');
} catch (e) {
if (result.status === 'verifyFailed') {
// this might happen if e.g. database intialization in progress.
// so we can't answer with 401 (would logout user)
// reproduced during usage of listRowsAutoRefreshSeconds
afLogger.error(e.stack);
afLogger.error(result.error.stack);
res.status(500).send('Failed to verify JWT token - something went wrong');
return;
}
if (!adminforthUser) {
if (result.status !== 'ok') {
res.status(401).send('Unauthorized by AdminForth');
} else {
req.adminUser = adminforthUser;
const toReturn: { error?: string, allowed: boolean } = { allowed: true };
await this.processAuthorizeCallbacks(adminforthUser, toReturn, res, {
body: req.body,
query: req.query,
headers: req.headers,
cookies: cookies as any,
requestUrl: req.url,
meta: {},
response: res
});
if (!toReturn.allowed) {
res.status(401).send('Unauthorized by AdminForth');
} else {
return handler(req, res, next);
}
return;
}

req.adminUser = result.adminUser;
return handler(req, res, next);
});
};
}
Expand Down
Loading