Skip to content

Commit

Permalink
wip: auth refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Oct 17, 2023
1 parent 30ee9b3 commit 5fae453
Show file tree
Hide file tree
Showing 27 changed files with 1,309 additions and 1,134 deletions.
12 changes: 6 additions & 6 deletions core/components/AdminVault/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,8 @@ export default class AdminVault {
* Add a new admin to the admins file
* NOTE: I'm fully aware this coud be optimized. Leaving this way to improve readability and error verbosity
* @param {string} name
* @param {object} citizenfxData or false
* @param {object} discordData or false
* @param {object|undefined} citizenfxData or false
* @param {object|undefined} discordData or false
* @param {string} password
* @param {array} permissions
*/
Expand Down Expand Up @@ -343,10 +343,10 @@ export default class AdminVault {
/**
* Edit admin and save to the admins file
* @param {string} name
* @param {string} password
* @param {object} citizenfxData or false
* @param {object} discordData or false
* @param {array} permissions
* @param {string|null} password
* @param {object|undefined} citizenfxData or false
* @param {object|undefined} discordData or false
* @param {array|undefined} permissions
*/
async editAdmin(name, password, citizenfxData, discordData, permissions) {
if (this.admins == false) throw new Error('Admins not set');
Expand Down
35 changes: 17 additions & 18 deletions core/components/AdminVault/providers/CitizenFX.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const modulename = 'AdminVault:CitizenFXProvider';
import crypto from 'node:crypto'
import crypto from 'node:crypto';
import { Issuer, custom } from 'openid-client';

import consoleFactory from '@extras/console';
Expand All @@ -24,7 +24,7 @@ export default class CitizenFXProvider {
try {
//NOTE: using static config due to performance concerns
// const fivemIssuer = await Issuer.discover('https://idms.fivem.net/.well-known/openid-configuration');
const fivemIssuer = new Issuer({'issuer':'https://idms.fivem.net', 'jwks_uri':'https://idms.fivem.net/.well-known/openid-configuration/jwks', 'authorization_endpoint':'https://idms.fivem.net/connect/authorize', 'token_endpoint':'https://idms.fivem.net/connect/token', 'userinfo_endpoint':'https://idms.fivem.net/connect/userinfo', 'end_session_endpoint':'https://idms.fivem.net/connect/endsession', 'check_session_iframe':'https://idms.fivem.net/connect/checksession', 'revocation_endpoint':'https://idms.fivem.net/connect/revocation', 'introspection_endpoint':'https://idms.fivem.net/connect/introspect', 'device_authorization_endpoint':'https://idms.fivem.net/connect/deviceauthorization', 'frontchannel_logout_supported':true, 'frontchannel_logout_session_supported':true, 'backchannel_logout_supported':true, 'backchannel_logout_session_supported':true, 'scopes_supported':['openid', 'email', 'identify', 'offline_access'], 'claims_supported':['sub', 'email', 'email_verified', 'nameid', 'name', 'picture', 'profile'], 'grant_types_supported':['authorization_code', 'client_credentials', 'refresh_token', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code'], 'response_types_supported':['code', 'token', 'id_token', 'id_token token', 'code id_token', 'code token', 'code id_token token'], 'response_modes_supported':['form_post', 'query', 'fragment'], 'token_endpoint_auth_methods_supported':['client_secret_basic', 'client_secret_post'], 'subject_types_supported':['public'], 'id_token_signing_alg_values_supported':['RS256'], 'code_challenge_methods_supported':['plain', 'S256'], 'request_parameter_supported':true});
const fivemIssuer = new Issuer({ 'issuer': 'https://idms.fivem.net', 'jwks_uri': 'https://idms.fivem.net/.well-known/openid-configuration/jwks', 'authorization_endpoint': 'https://idms.fivem.net/connect/authorize', 'token_endpoint': 'https://idms.fivem.net/connect/token', 'userinfo_endpoint': 'https://idms.fivem.net/connect/userinfo', 'end_session_endpoint': 'https://idms.fivem.net/connect/endsession', 'check_session_iframe': 'https://idms.fivem.net/connect/checksession', 'revocation_endpoint': 'https://idms.fivem.net/connect/revocation', 'introspection_endpoint': 'https://idms.fivem.net/connect/introspect', 'device_authorization_endpoint': 'https://idms.fivem.net/connect/deviceauthorization', 'frontchannel_logout_supported': true, 'frontchannel_logout_session_supported': true, 'backchannel_logout_supported': true, 'backchannel_logout_session_supported': true, 'scopes_supported': ['openid', 'email', 'identify', 'offline_access'], 'claims_supported': ['sub', 'email', 'email_verified', 'nameid', 'name', 'picture', 'profile'], 'grant_types_supported': ['authorization_code', 'client_credentials', 'refresh_token', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code'], 'response_types_supported': ['code', 'token', 'id_token', 'id_token token', 'code id_token', 'code token', 'code id_token token'], 'response_modes_supported': ['form_post', 'query', 'fragment'], 'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'], 'subject_types_supported': ['public'], 'id_token_signing_alg_values_supported': ['RS256'], 'code_challenge_methods_supported': ['plain', 'S256'], 'request_parameter_supported': true });

this.client = new fivemIssuer.Client({
client_id: 'txadmin_test',
Expand All @@ -48,14 +48,13 @@ export default class CitizenFXProvider {
* Returns the Provider Auth URL
* @param {string} state
* @param {string} redirectUri
* @returns {(string)} the auth url or throws an error
*/
async getAuthURL(redirectUri, stateKern) {
getAuthURL(redirectUri, stateKern) {
if (!this.ready) throw new Error(`${modulename} is not ready`);

const stateSeed = `txAdmin:${stateKern}`;
const state = crypto.createHash('SHA1').update(stateSeed).digest('hex');
const url = await this.client.authorizationUrl({
const url = this.client.authorizationUrl({
redirect_uri: redirectUri,
state: state,
response_type: 'code',
Expand All @@ -72,7 +71,6 @@ export default class CitizenFXProvider {
* @param {object} ctx
* @param {string} redirectUri the redirect uri originally used
* @param {string} stateKern
* @returns {(object)} tokenSet or throws an error
*/
async processCallback(ctx, redirectUri, stateKern) {
if (!this.ready) throw new Error(`${modulename} is not ready`);
Expand All @@ -86,7 +84,7 @@ export default class CitizenFXProvider {
const stateExpected = crypto.createHash('SHA1').update(stateSeed).digest('hex');

//Exchange code for token
const tokenSet = await this.client.callback(redirectUri, params, {state: stateExpected});
const tokenSet = await this.client.callback(redirectUri, params, { state: stateExpected });
if (typeof tokenSet !== 'object') throw new Error('tokenSet is not an object');
if (typeof tokenSet.access_token == 'undefined') throw new Error('access_token not present');
if (typeof tokenSet.expires_at == 'undefined') throw new Error('expires_at not present');
Expand All @@ -97,8 +95,6 @@ export default class CitizenFXProvider {
//================================================================
/**
* Gets user info via access token
* @param {string} accessToken
* @returns {(string)} userInfo or throws an error
*/
async getUserInfo(accessToken) {
if (!this.ready) throw new Error(`${modulename} is not ready`);
Expand All @@ -110,7 +106,12 @@ export default class CitizenFXProvider {
if (typeof userInfo.profile != 'string' || !userInfo.profile.length) throw new Error('profile not present');
if (typeof userInfo.nameid != 'string' || !userInfo.nameid.length) throw new Error('nameid not present');
if (typeof userInfo.picture != 'string' || !userInfo.picture.length) userInfo.picture = null;
return userInfo;
return {
name: userInfo.name,
profile: userInfo.profile,
nameid: userInfo.nameid,
picture: userInfo.picture,
};
}


Expand All @@ -122,17 +123,15 @@ export default class CitizenFXProvider {
* @param {object} tokenSet
* @param {object} userInfo
* @param {string} identifier
* @returns {object}
*/
async getUserSession(tokenSet, userInfo, identifier) {
async getUserSessionInfo(tokenSet, userInfo, identifier) {
return {
provider: 'citizenfx',
provider_uid: userInfo.name,
provider_identifier: identifier,
// expires_at: tokenSet.expires_at,
expires_at: Math.round(Date.now() / 1000) + 86400,
type: 'cfxre',
forumUsername: userInfo.name,
identifier: identifier,
// expires_at: tokenSet.expires_at * 1000,
expires_at: Date.now() + 86_400_000, //24h
picture: userInfo.picture,
csrfToken: globals.adminVault.genCsrfToken(),
};
}
};
223 changes: 223 additions & 0 deletions core/components/WebServer/authLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
const modulename = 'WebServer:AuthLogic';
import { z } from "zod";
import { convars } from '@core/globalData';
import consoleFactory from '@extras/console';
import TxAdmin from "@core/txAdmin";
import AdminLogger from "../Logger/handlers/admin";
const console = consoleFactory(modulename);


/**
* Admin class to be used as ctx.admin
*/
export class AuthedAdmin {
public readonly name: string;
public readonly permissions: string[];
public readonly isMaster: boolean;
public readonly isTempPassword: boolean;
readonly #adminLogger: AdminLogger;

constructor(vaultAdmin: any, adminLogger: AdminLogger) {
this.name = vaultAdmin.name;
this.isMaster = vaultAdmin.master;
this.permissions = vaultAdmin.permissions;
this.isTempPassword = (typeof vaultAdmin.password_temporary !== 'undefined');
this.#adminLogger = adminLogger;
}

/**
* Logs an action to the console and the action logger
*/
public logAction(action: string): void {
this.#adminLogger.write(this.name, action);
};

/**
* Logs a command to the console and the action logger
*/
public logCommand(data: string): void {
this.#adminLogger.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;
}
}

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, txAdmin: TxAdmin) => ({
success: true,
admin: new AuthedAdmin(vaultAdmin, txAdmin.logger.admin),
} 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(),
expires_at: z.literal(false),
password_hash: z.string(),
});
const validCfxreSessAuthSchema = z.object({
type: z.literal('cfxre'),
username: z.string(),
csrfToken: z.string(),
expires_at: z.number(),
forumUsername: z.string(),
identifier: z.string(),
});
const validSessAuthSchema = z.discriminatedUnion('type', [
validPassSessAuthSchema,
validCfxreSessAuthSchema
]);


/**
* Autentication logic used in both websocket and webserver
*/
export const normalAuthLogic = (
txAdmin: TxAdmin,
sess: any
): AuthLogicReturnType => {
try {
// Parsing session auth
const validationResult = validSessAuthSchema.safeParse(sess?.auth);
if (!validationResult.success) {
return failResp();
}
const sessAuth = validationResult.data;

// Checking for expiration
if (sessAuth.expires_at !== false && Date.now() > sessAuth.expires_at) {
return failResp(`Expired session from '${sess.auth.username}'.`);
}

// Searching for admin in AdminVault
const vaultAdmin = txAdmin.adminVault.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, txAdmin);
} 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, txAdmin);
} 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 = (
txAdmin: TxAdmin,
reqIP: string,
reqHeader: { [key: string]: unknown }
): AuthLogicReturnType => {
try {
// Check sus IPs
if (
!convars.loopbackInterfaces.includes(reqIP)
&& !convars.isZapHosting
&& !txAdmin.webServer.config.disableNuiSourceCheck
) {
console.verbose.warn(`NUI Auth Failed: reqIP "${reqIP}" not in ${JSON.stringify(convars.loopbackInterfaces)}.`);
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'] !== txAdmin.webServer.luaComToken) {
console.verbose.warn(`NUI Auth Failed: token received '${reqHeader['x-txadmin-token']}' !== expected '${txAdmin.webServer.luaComToken}'.`);
return failResp('Unauthorized: token value');
}

// Check identifier array
const identifiers = reqHeader['x-txadmin-identifiers']
.split(',')
.map((i) => i.trim().toLowerCase())
.filter((i) => i.length);
if (!identifiers.length) {
return failResp('Unauthorized: empty identifier array');
}

// Searching for admin in AdminVault
const vaultAdmin = txAdmin.adminVault.getAdminByIdentifiers(identifiers);
if (!vaultAdmin) {
//this one is handled differently in resource/menu/client/cl_base.lua
return failResp('admin_not_found');
}
return successResp(vaultAdmin, txAdmin);
} catch (error) {
console.debug(`Error validating session data: ${(error as Error).message}`);
return failResp('Error validating auth header');
}
};
Loading

0 comments on commit 5fae453

Please sign in to comment.