monitor/core/modules/WebServer/authLogic.ts
2025-04-16 22:30:27 +07:00

270 lines
8.7 KiB
TypeScript

const modulename = 'WebServer:AuthLogic';
import { z } from "zod";
import { txEnv } from '@core/globalData';
import consoleFactory from '@lib/console';
import type { SessToolsType } from "./middlewares/sessionMws";
import { ReactAuthDataType } from "@shared/authApiTypes";
const console = consoleFactory(modulename);
/**
* Admin class to be used as ctx.admin
*/
export class AuthedAdmin {
public readonly name: string;
public readonly isMaster: boolean;
public readonly permissions: string[];
public readonly isTempPassword: boolean;
public readonly profilePicture: string | undefined;
public readonly csrfToken?: string;
constructor(vaultAdmin: any, csrfToken?: string) {
this.name = vaultAdmin.name;
this.isMaster = vaultAdmin.master;
this.permissions = vaultAdmin.permissions;
this.isTempPassword = (typeof vaultAdmin.password_temporary !== 'undefined');
this.csrfToken = csrfToken;
const cachedPfp = txCore.cacheStore.get(`admin:picture:${vaultAdmin.name}`);
this.profilePicture = typeof cachedPfp === 'string' ? cachedPfp : undefined;
}
/**
* Logs an action to the console and the action logger
*/
public logAction(action: string): void {
txCore.logger.admin.write(this.name, action);
};
/**
* Logs a command to the console and the action logger
*/
public logCommand(data: string): void {
txCore.logger.admin.write(this.name, data, 'command');
};
/**
* Returns if admin has permission or not - no message is printed
*/
hasPermission(perm: string): boolean {
try {
if (perm === 'master') {
return this.isMaster;
}
return (
this.isMaster
|| this.permissions.includes('all_permissions')
|| this.permissions.includes(perm)
);
} catch (error) {
console.verbose.warn(`Error validating permission '${perm}' denied.`);
return false;
}
}
/**
* Test for a permission and prints warn if test fails and verbose
*/
testPermission(perm: string, fromCtx: string): boolean {
if (!this.hasPermission(perm)) {
console.verbose.warn(`[${this.name}] Permission '${perm}' denied.`, fromCtx);
return false;
}
return true;
}
/**
* Returns the data used for the frontend or sv_admins.lua
*/
getAuthData(): ReactAuthDataType {
return {
name: this.name,
permissions: this.isMaster ? ['all_permissions'] : this.permissions,
isMaster: this.isMaster,
isTempPassword: this.isTempPassword,
profilePicture: this.profilePicture,
csrfToken: this.csrfToken ?? 'not_set',
}
}
}
export type AuthedAdminType = InstanceType<typeof AuthedAdmin>;
/**
* Return type helper - null reason indicates nothing to print
*/
type AuthLogicReturnType = {
success: true,
admin: AuthedAdmin;
} | {
success: false;
rejectReason?: string;
};
const successResp = (vaultAdmin: any, csrfToken?: string) => ({
success: true,
admin: new AuthedAdmin(vaultAdmin, csrfToken),
} as const)
const failResp = (reason?: string) => ({
success: false,
rejectReason: reason,
} as const)
/**
* ZOD schemas for session auth
*/
const validPassSessAuthSchema = z.object({
type: z.literal('password'),
username: z.string(),
csrfToken: z.string(),
expiresAt: z.literal(false),
password_hash: z.string(),
});
export type PassSessAuthType = z.infer<typeof validPassSessAuthSchema>;
const validCfxreSessAuthSchema = z.object({
type: z.literal('cfxre'),
username: z.string(),
csrfToken: z.string(),
expiresAt: z.number(),
identifier: z.string(),
});
export type CfxreSessAuthType = z.infer<typeof validCfxreSessAuthSchema>;
const validSessAuthSchema = z.discriminatedUnion('type', [
validPassSessAuthSchema,
validCfxreSessAuthSchema
]);
/**
* Autentication logic used in both websocket and webserver, for both web and nui requests.
*/
export const checkRequestAuth = (
reqHeader: { [key: string]: unknown },
reqIp: string,
isLocalRequest: boolean,
sessTools: SessToolsType,
) => {
return typeof reqHeader['x-txadmin-token'] === 'string'
? nuiAuthLogic(reqIp, isLocalRequest, reqHeader)
: normalAuthLogic(sessTools);
}
/**
* Autentication logic used in both websocket and webserver
*/
export const normalAuthLogic = (
sessTools: SessToolsType
): AuthLogicReturnType => {
try {
// Getting session
const sess = sessTools.get();
if (!sess) {
return failResp();
}
// Parsing session auth
const validationResult = validSessAuthSchema.safeParse(sess?.auth);
if (!validationResult.success) {
return failResp();
}
const sessAuth = validationResult.data;
// Checking for expiration
if (sessAuth.expiresAt !== false && Date.now() > sessAuth.expiresAt) {
return failResp(`Expired session from '${sess.auth?.username}'.`);
}
// Searching for admin in AdminStore
const vaultAdmin = txCore.adminStore.getAdminByName(sessAuth.username);
if (!vaultAdmin) {
return failResp(`Admin '${sessAuth.username}' not found.`);
}
// Checking for auth types
if (sessAuth.type === 'password') {
if (vaultAdmin.password_hash !== sessAuth.password_hash) {
return failResp(`Password hash doesn't match for '${sessAuth.username}'.`);
}
return successResp(vaultAdmin, sessAuth.csrfToken);
} else if (sessAuth.type === 'cfxre') {
if (
typeof vaultAdmin.providers.citizenfx !== 'object'
|| vaultAdmin.providers.citizenfx.identifier !== sessAuth.identifier
) {
return failResp(`Cfxre identifier doesn't match for '${sessAuth.username}'.`);
}
return successResp(vaultAdmin, sessAuth.csrfToken);
} else {
return failResp('Invalid auth type.');
}
} catch (error) {
console.debug(`Error validating session data: ${(error as Error).message}`);
return failResp('Error validating session data.');
}
};
/**
* Autentication & authorization logic used in for nui requests
*/
export const nuiAuthLogic = (
reqIp: string,
isLocalRequest: boolean,
reqHeader: { [key: string]: unknown }
): AuthLogicReturnType => {
try {
// Check sus IPs
if (
!isLocalRequest
&& !txEnv.isZapHosting
&& !txConfig.webServer.disableNuiSourceCheck
) {
console.verbose.warn(`NUI Auth Failed: reqIp "${reqIp}" not a local or allowed address.`);
return failResp('Invalid Request: source');
}
// Check missing headers
if (typeof reqHeader['x-txadmin-token'] !== 'string') {
return failResp('Invalid Request: token header');
}
if (typeof reqHeader['x-txadmin-identifiers'] !== 'string') {
return failResp('Invalid Request: identifiers header');
}
// Check token value
if (reqHeader['x-txadmin-token'] !== txCore.webServer.luaComToken) {
const expected = txCore.webServer.luaComToken;
const censoredExpected = expected.slice(0, 6) + '...' + expected.slice(-6);
console.verbose.warn(`NUI Auth Failed: token received '${reqHeader['x-txadmin-token']}' !== expected '${censoredExpected}'.`);
return failResp('Unauthorized: token value');
}
// Check identifier array
const identifiers = reqHeader['x-txadmin-identifiers']
.split(',')
.filter((i) => i.length);
if (!identifiers.length) {
return failResp('Unauthorized: empty identifier array');
}
// Searching for admin in AdminStore
const vaultAdmin = txCore.adminStore.getAdminByIdentifiers(identifiers);
if (!vaultAdmin) {
if(!reqHeader['x-txadmin-identifiers'].includes('license:')) {
return failResp('Unauthorized: you do not have a license identifier, which means the server probably has sv_lan enabled. Please disable sv_lan and restart the server to use the in-game menu.');
} else {
//this one is handled differently in resource/menu/client/cl_base.lua
return failResp('nui_admin_not_found');
}
}
return successResp(vaultAdmin, undefined);
} catch (error) {
console.debug(`Error validating session data: ${(error as Error).message}`);
return failResp('Error validating auth header');
}
};