diff --git a/core/components/AdminVault/index.js b/core/components/AdminVault/index.js index 1b1e5e097..cfb41b6c6 100644 --- a/core/components/AdminVault/index.js +++ b/core/components/AdminVault/index.js @@ -71,7 +71,7 @@ export default class AdminVault { try { this.providers = { discord: false, - citizenfx: new CitizenFXProvider(null), + citizenfx: new CitizenFXProvider(), }; } catch (error) { throw new Error(`Failed to load providers with error: ${error.message}`); diff --git a/core/components/AdminVault/providers/CitizenFX.js b/core/components/AdminVault/providers/CitizenFX.js deleted file mode 100644 index 2e9061963..000000000 --- a/core/components/AdminVault/providers/CitizenFX.js +++ /dev/null @@ -1,137 +0,0 @@ -const modulename = 'AdminVault:CitizenFXProvider'; -import crypto from 'node:crypto'; -import { Issuer, custom } from 'openid-client'; - -import consoleFactory from '@extras/console'; -const console = consoleFactory(modulename); - - -export default class CitizenFXProvider { - constructor(config) { - this.config = config; - this.client = null; - this.ready = false; - - this.setClient(); - } - - - //================================================================ - /** - * Do OpenID Connect auto-discover on CitizenFX endpoint - */ - async setClient() { - 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 }); - - this.client = new fivemIssuer.Client({ - client_id: 'txadmin_test', - client_secret: 'txadmin_test', - response_types: ['openid'], - }); - this.client[custom.clock_tolerance] = 2 * 60 * 60; //Two hours due to the DST change. Reduce to 300s. - custom.setHttpOptionsDefaults({ - timeout: 10000, - }); - console.verbose.log('CitizenFX Provider configured.'); - this.ready = true; - } catch (error) { - console.error(`Failed to create client with error: ${error.message}`); - } - } - - - //================================================================ - /** - * Returns the Provider Auth URL - * @param {string} state - * @param {string} redirectUri - */ - 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 = this.client.authorizationUrl({ - redirect_uri: redirectUri, - state: state, - response_type: 'code', - scope: 'openid identify', - }); - if (typeof url !== 'string') throw new Error('url is not string'); - return url; - } - - - //================================================================ - /** - * Processes the callback and returns the tokenSet - * @param {object} ctx - * @param {string} redirectUri the redirect uri originally used - * @param {string} stateKern - */ - async processCallback(ctx, redirectUri, stateKern) { - if (!this.ready) throw new Error(`${modulename} is not ready`); - - //Process the request - const params = this.client.callbackParams(ctx); - if (typeof params.code == 'undefined') throw new Error('code not present'); - - //Check the state - const stateSeed = `txAdmin:${stateKern}`; - const stateExpected = crypto.createHash('SHA1').update(stateSeed).digest('hex'); - - //Exchange code for token - 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'); - return tokenSet; - } - - - //================================================================ - /** - * Gets user info via access token - */ - async getUserInfo(accessToken) { - if (!this.ready) throw new Error(`${modulename} is not ready`); - - //Perform introspection - const userInfo = await this.client.userinfo(accessToken); - if (typeof userInfo !== 'object') throw new Error('userInfo is not an object'); - if (typeof userInfo.name != 'string' || !userInfo.name.length) throw new Error('name not present'); - 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 { - name: userInfo.name, - profile: userInfo.profile, - nameid: userInfo.nameid, - picture: userInfo.picture, - }; - } - - - //================================================================ - /** - * Returns the session auth object - * NOTE: increasing session duration to 24 hours since we do not have refresh tokens - * - * @param {object} tokenSet - * @param {object} userInfo - * @param {string} identifier - */ - async getUserSessionInfo(tokenSet, userInfo, identifier) { - return { - type: 'cfxre', - forumUsername: userInfo.name, - identifier: identifier, - // expires_at: tokenSet.expires_at * 1000, - expires_at: Date.now() + 86_400_000, //24h - picture: userInfo.picture, - }; - } -}; diff --git a/core/components/AdminVault/providers/CitizenFX.ts b/core/components/AdminVault/providers/CitizenFX.ts new file mode 100644 index 000000000..1ac204c32 --- /dev/null +++ b/core/components/AdminVault/providers/CitizenFX.ts @@ -0,0 +1,118 @@ +const modulename = 'AdminVault:CitizenFXProvider'; +import crypto from 'node:crypto'; +import { BaseClient, Issuer, custom } from 'openid-client'; + +import consoleFactory from '@extras/console'; +import { z } from 'zod'; +import { InitializedCtx } from '@core/components/WebServer/ctxTypes'; +const console = consoleFactory(modulename); + +const userInfoSchema = z.object({ + name: z.string().min(1), + profile: z.string().min(1), + nameid: z.string().min(1), +}); +export type UserInfoType = z.infer & { picture: string | undefined }; + + +export default class CitizenFXProvider { + private client?: BaseClient; + + constructor() { + //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 }); + + this.client = new fivemIssuer.Client({ + client_id: 'txadmin_test', + client_secret: 'txadmin_test', + response_types: ['openid'], + }); + this.client[custom.clock_tolerance] = 2 * 60 * 60; //Two hours due to the DST change. Reduce to 300s. + custom.setHttpOptionsDefaults({ + timeout: 10000, + }); + console.verbose.log('CitizenFX Provider configured.'); + } + + + /** + * Returns the Provider Auth URL + */ + getAuthURL(redirectUri: string, stateKern: string) { + if (!this.client) throw new Error(`${modulename} is not ready`); + + const stateSeed = `txAdmin:${stateKern}`; + const state = crypto.createHash('SHA1').update(stateSeed).digest('hex'); + const url = this.client.authorizationUrl({ + redirect_uri: redirectUri, + state: state, + response_type: 'code', + scope: 'openid identify', + }); + if (typeof url !== 'string') throw new Error('url is not string'); + return url; + } + + + /** + * Processes the callback and returns the tokenSet + * @param {object} ctx + */ + async processCallback(ctx: InitializedCtx, redirectUri: string, stateKern: string) { + if (!this.client) throw new Error(`${modulename} is not ready`); + + //Process the request + const params = this.client.callbackParams(ctx as any); //FIXME: idk why it works, but it does + if (typeof params.code == 'undefined') throw new Error('code not present'); + + //Check the state + const stateSeed = `txAdmin:${stateKern}`; + const stateExpected = crypto.createHash('SHA1').update(stateSeed).digest('hex'); + + //Exchange code for token + 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'); + return tokenSet; + } + + + /** + * Gets user info via access token + */ + async getUserInfo(accessToken: string): Promise { + if (!this.client) throw new Error(`${modulename} is not ready`); + + //Perform introspection + const userInfo = await this.client.userinfo(accessToken); + const parsed = userInfoSchema.parse(userInfo); + let picture: string | undefined; + if (typeof userInfo.picture == 'string' && userInfo.picture.startsWith('https://')) { + picture = userInfo.picture; + } + + return { ...parsed, picture }; + } + + + /** + * Returns the session auth object + * NOTE: increasing session duration to 24 hours since we do not have refresh tokens + * + * @param {object} tokenSet + * @param {object} userInfo + * @param {string} identifier + */ + async getUserSessionInfo(tokenSet, userInfo, identifier: string) { + return { + type: 'cfxre', + forumUsername: userInfo.name, + identifier: identifier, + // expires_at: tokenSet.expires_at * 1000, + expires_at: Date.now() + 86_400_000, //24h + picture: userInfo.picture, + }; + } +}; diff --git a/core/components/WebServer/authLogic.ts b/core/components/WebServer/authLogic.ts index 89cb2ca09..ee4addddc 100644 --- a/core/components/WebServer/authLogic.ts +++ b/core/components/WebServer/authLogic.ts @@ -3,7 +3,6 @@ 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); @@ -15,28 +14,32 @@ export class AuthedAdmin { public readonly permissions: string[]; public readonly isMaster: boolean; public readonly isTempPassword: boolean; - readonly #adminLogger: AdminLogger; + public readonly profilePicture: string | undefined; + readonly #txAdmin: TxAdmin; - constructor(vaultAdmin: any, adminLogger: AdminLogger) { + constructor(txAdmin: TxAdmin, vaultAdmin: any) { + this.#txAdmin = txAdmin; this.name = vaultAdmin.name; this.isMaster = vaultAdmin.master; this.permissions = vaultAdmin.permissions; this.isTempPassword = (typeof vaultAdmin.password_temporary !== 'undefined'); - this.#adminLogger = adminLogger; + + const cachedPfp = txAdmin.persistentCache.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 { - this.#adminLogger.write(this.name, action); + this.#txAdmin.logger.admin.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'); + this.#txAdmin.logger.admin.write(this.name, data, 'command'); }; /** @@ -83,9 +86,9 @@ type AuthLogicReturnType = { success: false; rejectReason?: string; }; -const successResp = (vaultAdmin: any, txAdmin: TxAdmin) => ({ +const successResp = (txAdmin: TxAdmin, vaultAdmin: any) => ({ success: true, - admin: new AuthedAdmin(vaultAdmin, txAdmin.logger.admin), + admin: new AuthedAdmin(txAdmin, vaultAdmin), } as const) const failResp = (reason?: string) => ({ success: false, @@ -163,7 +166,7 @@ export const normalAuthLogic = ( if (vaultAdmin.password_hash !== sessAuth.password_hash) { return failResp(`Password hash doesn't match for '${sessAuth.username}'.`); } - return successResp(vaultAdmin, txAdmin); + return successResp(txAdmin, vaultAdmin); } else if (sessAuth.type === 'cfxre') { if ( typeof vaultAdmin.providers.citizenfx !== 'object' @@ -171,7 +174,7 @@ export const normalAuthLogic = ( ) { return failResp(`Cfxre identifier doesn't match for '${sessAuth.username}'.`); } - return successResp(vaultAdmin, txAdmin); + return successResp(txAdmin, vaultAdmin); } else { return failResp('Invalid auth type.'); } diff --git a/core/components/WebServer/getReactIndex.ts b/core/components/WebServer/getReactIndex.ts index 1cca169ef..c4655189e 100644 --- a/core/components/WebServer/getReactIndex.ts +++ b/core/components/WebServer/getReactIndex.ts @@ -93,7 +93,7 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { isMaster: authedAdmin.isMaster, permissions: authedAdmin.permissions, isTempPassword: authedAdmin.isTempPassword, - profilePicture: null, + profilePicture: authedAdmin.profilePicture, }, csrfToken: (ctx.session?.auth?.csrfToken) ? ctx.session.auth.csrfToken : 'not_set', } diff --git a/core/webroutes/authentication/addMaster.ts b/core/webroutes/authentication/addMaster.ts index a3c6b0067..955830e90 100644 --- a/core/webroutes/authentication/addMaster.ts +++ b/core/webroutes/authentication/addMaster.ts @@ -1,4 +1,5 @@ const modulename = 'WebServer:AuthAddMaster'; +import { UserInfoType } from '@core/components/AdminVault/providers/CitizenFX'; import { InitializedCtx } from '@core/components/WebServer/ctxTypes'; import consoleFactory from '@extras/console'; const console = consoleFactory(modulename); @@ -100,7 +101,13 @@ async function handleCallback(ctx: InitializedCtx) { let tokenSet; try { const currentURL = ctx.protocol + '://' + ctx.get('host') + '/auth/addMaster/callback'; - tokenSet = await ctx.txAdmin.adminVault.providers.citizenfx.processCallback(ctx, currentURL, ctx.session.externalKey); + tokenSet = await ctx.txAdmin.adminVault.providers.citizenfx.processCallback( + ctx, + currentURL, + ctx.session.externalKey + ); + if (!tokenSet) throw new Error('tokenSet is undefined'); + if (!tokenSet.access_token) throw new Error('tokenSet.access_token is undefined'); } catch (e) { const error = e as any; //couldn't really test those errors, but tested in the past and they worked console.warn(`Code Exchange error: ${error.message}`); @@ -185,7 +192,7 @@ async function handleSave(ctx: InitializedCtx) { //Checking if session is still present if ( typeof ctx.session.tmpAddMasterUserInfo === 'undefined' - || typeof ctx.session.tmpAddMasterUserInfo.name !== 'string' + || typeof ctx.session.tmpAddMasterUserInfo?.name !== 'string' ) { return returnJustMessage( ctx, @@ -193,31 +200,28 @@ async function handleSave(ctx: InitializedCtx) { 'You may have restarted txAdmin right before entering this page. Please try again.', ); } + const userInfo = ctx.session.tmpAddMasterUserInfo as UserInfoType; //Getting identifier let identifier; try { - const res = /\/user\/(\d{1,8})/.exec(ctx.session.tmpAddMasterUserInfo.nameid); + const res = /\/user\/(\d{1,8})/.exec(userInfo.nameid); //@ts-expect-error identifier = `fivem:${res[1]}`; } catch (error) { return returnJustMessage( ctx, 'Invalid nameid identifier.', - `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${ctx.session.tmpAddMasterUserInfo.nameid.toString()}`, + `Could not extract the user identifier from the URL below. Please report this to the txAdmin dev team.\n${userInfo.nameid.toString()}`, ); } - if (typeof ctx.session.tmpAddMasterUserInfo.picture !== 'string') { - ctx.session.tmpAddMasterUserInfo.picture = null; - } - //Creating admins file try { ctx.txAdmin.adminVault.createAdminsFile( - ctx.session.tmpAddMasterUserInfo.name, + userInfo.name, identifier, - ctx.session.tmpAddMasterUserInfo, + userInfo, password, true ); @@ -229,14 +233,22 @@ async function handleSave(ctx: InitializedCtx) { ); } + //If the user has a picture, save it to the cache + if (userInfo.picture) { + ctx.txAdmin.persistentCache.set( + `admin:picture:${userInfo.name}`, + userInfo.picture + ); + } + //Login user try { ctx.session.auth = await ctx.txAdmin.adminVault.providers.citizenfx.getUserSessionInfo( ctx.session.tmpAddMasterTokenSet, - ctx.session.tmpAddMasterUserInfo, + userInfo, identifier, ); - ctx.session.auth.username = ctx.session.tmpAddMasterUserInfo.name; + ctx.session.auth.username = userInfo.name; ctx.session.auth.csrfToken = ctx.txAdmin.adminVault.genCsrfToken(); delete ctx.session.tmpAddMasterTokenSet; delete ctx.session.tmpAddMasterUserInfo; diff --git a/core/webroutes/authentication/providerCallback.ts b/core/webroutes/authentication/providerCallback.ts index b664d2144..e655944d1 100644 --- a/core/webroutes/authentication/providerCallback.ts +++ b/core/webroutes/authentication/providerCallback.ts @@ -51,7 +51,13 @@ export default async function AuthProviderCallback(ctx: InitializedCtx) { let tokenSet; try { const currentURL = ctx.protocol + '://' + ctx.get('host') + `/auth/${provider}/callback`; - tokenSet = await ctx.txAdmin.adminVault.providers.citizenfx.processCallback(ctx, currentURL, ctx.session.externalKey); + tokenSet = await ctx.txAdmin.adminVault.providers.citizenfx.processCallback( + ctx, + currentURL, + ctx.session.externalKey + ); + if (!tokenSet) throw new Error('tokenSet is undefined'); + if (!tokenSet.access_token) throw new Error('tokenSet.access_token is undefined'); } catch (e) { const error = e as any; //couldn't really test those errors, but tested in the past and they worked console.warn(`Code Exchange error: ${error.message}`); @@ -120,11 +126,18 @@ export default async function AuthProviderCallback(ctx: InitializedCtx) { //Save the updated provider identifier & data to the admins file await ctx.txAdmin.adminVault.refreshAdminSocialData(vaultAdmin.name, 'citizenfx', identifier, userInfo); + + //If the user has a picture, save it to the cache + if (userInfo.picture) { + ctx.txAdmin.persistentCache.set(`admin:picture:${vaultAdmin.name}`, userInfo.picture); + } ctx.txAdmin.logger.admin.write(vaultAdmin.name, `logged in from ${ctx.ip} via cfxre`); ctx.txAdmin.statisticsManager.loginOrigins.count(ctx.txVars.hostType); ctx.txAdmin.statisticsManager.loginMethods.count('citizenfx'); - const redirectPath = (isValidRedirectPath(ctx.session?.socialLoginRedirect)) ? ctx.session.socialLoginRedirect : '/'; + const redirectPath = (isValidRedirectPath(ctx.session?.socialLoginRedirect)) + ? ctx.session.socialLoginRedirect as string + : '/'; return ctx.response.redirect(redirectPath); } catch (error) { ctx.session.auth = {}; diff --git a/docs/dev_notes.md b/docs/dev_notes.md index 6bff476b1..b5c0f8222 100644 --- a/docs/dev_notes.md +++ b/docs/dev_notes.md @@ -14,9 +14,9 @@ Processo: - [x] test and commit all changed files - [x] check `playerDatabase.registerAction` expiration type error - [x] translation fix the `The player was xxxxx` lines in all files +- [x] corrigir comportamento dos profile pictures - [ ] renomear csrfToken e forumUsername pra snake case, mas no AuthedAdmin pode ficar tudo como camel - sim, colocar o csrfToken pra dentro do admin, e tb editar o preAuth -- [ ] corrigir comportamento dos profile pictures - [ ] checar o que fazer com as stats no `core/webroutes/authentication/self.ts` - [ ] escrever teste automatizado pros authMiddlewares e authLogic