From bc2cca01809ac0e499316800eb1fb886a76178e8 Mon Sep 17 00:00:00 2001 From: TrojanerHD Date: Sat, 18 Mar 2023 23:23:19 +0100 Subject: [PATCH] Add refresh token for each server Ask to authorize user whenever /permit command is used --- src/FeatureChecker.ts | 1 + src/common.ts | 12 +- src/messages/MessageHandler.ts | 24 ++- src/messages/PermitCommand.ts | 72 +++---- src/messages/permissions/Authentication.ts | 191 ++++++++++++------ .../permissions/CommandPermissions.ts | 119 ++++++----- src/settings/SettingsDB.ts | 6 + 7 files changed, 259 insertions(+), 166 deletions(-) diff --git a/src/FeatureChecker.ts b/src/FeatureChecker.ts index e3ae401..35d540c 100644 --- a/src/FeatureChecker.ts +++ b/src/FeatureChecker.ts @@ -71,6 +71,7 @@ export default class FeatureChecker { permissionRoles: [], roles: [], streamers: [], + refreshToken: '', }).catch(console.error); } else this.warning(`Limited features due to problems ${guildInfo}`); } diff --git a/src/common.ts b/src/common.ts index 850e822..c62e741 100644 --- a/src/common.ts +++ b/src/common.ts @@ -3,6 +3,10 @@ import { ClientRequest, IncomingMessage } from 'http'; import { request, RequestOptions } from 'https'; import RoleChannelManager from './roles/RoleChannelManager'; +interface AccessTokens { + [guildId: string]: AccessToken; +} + interface AccessToken { access_token: string; expires_at: number; @@ -15,7 +19,7 @@ export default abstract class Common { /** * The access token for the Discord API */ - public static _discordAccessToken?: AccessToken; + public static _discordAccessTokens: AccessTokens = {}; /** * Sanitizes messages for safe use in message contents @@ -30,8 +34,8 @@ export default abstract class Common { * Checks whether the Discord token is valid * @returns Whether the Discord token is valid */ - public static accessTokenValid(): boolean { - return (Common._discordAccessToken?.expires_at ?? 0) > Date.now(); + public static accessTokenValid(guildId: string): boolean { + return (Common._discordAccessTokens[guildId]?.expires_at ?? 0) > Date.now(); } /** @@ -46,8 +50,6 @@ export default abstract class Common { ) ?? new RoleChannelManager(guild) ); } - - } /** diff --git a/src/messages/MessageHandler.ts b/src/messages/MessageHandler.ts index 1b76d70..6b35e01 100644 --- a/src/messages/MessageHandler.ts +++ b/src/messages/MessageHandler.ts @@ -2,6 +2,8 @@ import DiscordClient from '../DiscordClient'; import { ApplicationCommand, ApplicationCommandData, + Collection, + Guild, GuildResolvable, Message, } from 'discord.js'; @@ -46,17 +48,27 @@ export default class MessageHandler { /** * Deploys all commands on all servers and for DMs */ - public static addCommands(): void { + public static addCommands(guild?: Guild): void { const commands: ApplicationCommandData[] = MessageHandler._commands.map( (command: Command): ApplicationCommandData => command.deploy ); - const commandPermissions: CommandPermissions = new CommandPermissions(); + if (DiscordClient._client.application === undefined) return; - DiscordClient._client.application?.commands - .set(commands) - .then(commandPermissions.onCommandsSet.bind(commandPermissions)) - .catch(console.error); + const setCommands: Promise< + Collection> + > = DiscordClient._client.application!.commands.set(commands); + + if (guild !== undefined) { + const commandPermissions: CommandPermissions = new CommandPermissions( + guild + ); + setCommands.then( + commandPermissions.onCommandsSet.bind(commandPermissions) + ); + } + + setCommands.catch(console.error); } /** diff --git a/src/messages/PermitCommand.ts b/src/messages/PermitCommand.ts index 63100a7..b090a1f 100644 --- a/src/messages/PermitCommand.ts +++ b/src/messages/PermitCommand.ts @@ -9,6 +9,7 @@ import Command from './Command'; import GuildSettings from '../settings/GuildSettings'; import { GuildInfo } from '../settings/SettingsDB'; import MessageHandler from './MessageHandler'; +import Authentication from './permissions/Authentication'; export default class PermitCommand extends Command { deploy: ChatInputApplicationCommandData = { @@ -69,82 +70,69 @@ export default class PermitCommand extends Command { const info: GuildInfo = await GuildSettings.settings(interaction.guildId); + let reply: string = ''; + switch (args[0].name) { case 'manage': const options: CommandInteractionOption[] = args[0].options!; const newRoleId: string = options[1].role!.id; const newRoleIdFormatted: string = `<@&${newRoleId}>`; + reply = `Role ${newRoleIdFormatted} `; + const alreadyAdded: boolean = info.permissionRoles.some( (role: string): boolean => newRoleId === role ); switch (options[0].value) { case 'add': if (alreadyAdded) { - interaction - .reply({ - content: `Role ${newRoleIdFormatted} is already a permitted role`, - ephemeral: true, - }) - .catch(console.error); - return; + reply += 'is already a permitted role'; + break; } info.permissionRoles.push(newRoleId); - interaction - .reply({ - content: `The role ${newRoleIdFormatted} has been added as a permitted role`, - ephemeral: true, - }) - .catch(console.error); + + reply += 'has been added as a permitted role'; break; case 'remove': if (!alreadyAdded) { - interaction - .reply({ - content: `Role ${newRoleIdFormatted} is not a permitted role`, - ephemeral: true, - }) - .catch(console.error); - return; + reply += 'is not a permitted role'; + break; } info.permissionRoles = info.permissionRoles.filter( (role: string) => newRoleId !== role ); - interaction - .reply({ - content: `Role ${newRoleIdFormatted} has been removed from the permitted roles`, - ephemeral: true, - }) - .catch(console.error); + + reply += 'has been removed from the permitted roles'; break; } break; case 'list': if (info.permissionRoles.length === 0) { - interaction - .reply({ - content: 'Currently, no roles are permitted', - ephemeral: true, - }) - .catch(console.error); - return; + reply = 'Currently, no roles are permitted'; + break; } - interaction - .reply({ - content: `Currently, the following roles are permitted: ${info.permissionRoles - .map((role: string) => `<@&${role}>`) - .join(', ')}`, - ephemeral: true, - }) - .catch(console.error); + + reply = `Currently, the following roles are permitted: ${info.permissionRoles + .map((role: string) => `<@&${role}>`) + .join(', ')}`; break; } + if ( + (await Authentication.getRefreshToken(interaction.guildId)) === undefined + ) { + reply += `\nFor this feature to work, please click the following link:\n${Authentication.createURL( + interaction.guildId + )}`; + } + + interaction.reply({ content: reply, ephemeral: true }); + await GuildSettings.saveSettings(interaction.guild!, info).catch( console.error ); - MessageHandler.addCommands(); + MessageHandler.addCommands(interaction.guild!); } } diff --git a/src/messages/permissions/Authentication.ts b/src/messages/permissions/Authentication.ts index f21b2f5..71831e8 100644 --- a/src/messages/permissions/Authentication.ts +++ b/src/messages/permissions/Authentication.ts @@ -1,14 +1,15 @@ import express, { Express } from 'express'; import DiscordClient from '../../DiscordClient'; import { URLSearchParams } from 'url'; -import { requestWrapper as request } from '../../common'; +import Common, { requestWrapper as request } from '../../common'; import { RequestOptions } from 'https'; import { Server } from 'http'; -import fs from 'fs'; -import Common from '../../common'; import Settings, { SettingsJSON } from '../../Settings'; +import { GuildInfo } from '../../settings/SettingsDB'; +import GuildSettings from '../../settings/GuildSettings'; +import { Guild } from 'discord.js'; -interface TokenResponse { +export interface TokenResponse { access_token: string; expires_in: number; scope: string; @@ -16,70 +17,117 @@ interface TokenResponse { refresh_token: string; } +export type MaybeTokenResponse = TokenResponse | { error: string } | void; + /** * Creates an express app for the user to authorize the bot to allow changing command permissions */ -export default class Authentication { - #app: Express = express(); - #server: Server; +export default abstract class Authentication { + static #app: Express = express(); + static #server?: Server = undefined; + static #listeners: { + guildId: string; + listener: (json?: MaybeTokenResponse) => void; + }[] = []; + + /** + * Adds a callback that gets executed whenever somebody authorizes + * @param listener The callback to be executed + */ + public static addListener( + guildId: string, + listener: (json: MaybeTokenResponse) => void + ) { + Authentication.#listeners.push({ guildId, listener }); + } + + public static startServer(): void { + if (Authentication.#server !== undefined) { + if (!Authentication.#server.listening) Authentication.listen(); + return; + } + if (Settings.settings.logging !== 'errors') + console.log('Start express server'); + Authentication.#app.get('/', (req, res): void => { + const request = Authentication.makeRequest( + req.query.code as string, + `${req.protocol}://${req.headers.host as string}${req.path}` + ).catch(console.error); + + request.then((response: MaybeTokenResponse): void => + Authentication.#listeners + .find( + (listener: { + guildId: string; + listener: (json?: MaybeTokenResponse) => void; + }) => req.query.state === listener.guildId + ) + ?.listener(response) + ); - constructor(callback: () => void) { - console.log( - `Warning: To update the command's permissions, please authenticate the application at https://discord.com/oauth2/authorize?client_id=${DiscordClient._client.application?.id}&scope=applications.commands.permissions.update&response_type=code&redirect_uri=` - ); - this.#app.get('/', (req, res): void => { - Authentication.makeRequest({ - code: req.query.code as string, - redirect: `${req.protocol}://${req.headers.host as string}${req.path}`, - }) - .then(callback) - .catch(console.error); res.send('Successfully authorized'); // Stop express app - this.#server.close(); + if (Authentication.#listeners.length - 1 <= 0) + Authentication.#server?.close(); }); + Authentication.listen(); + } + + private static listen(): void { if ( Settings.settings['express-port'] !== undefined && Settings.settings['express-port'] !== null && Settings.settings['express-port']! > 0 ) - this.#server = this.#app.listen(Settings.settings['express-port']); - else this.#server = this.#app.listen(); + Authentication.#server = Authentication.#app.listen( + Settings.settings['express-port'] + ); + else Authentication.#server = Authentication.#app.listen(); } /** * Get a new access token if a refresh token exists * @returns The access token request promise */ - static getAccessToken(): Promise { - if (process.env.DISCORD_REFRESH_TOKEN === undefined) - throw new Error('No refresh token'); - return this.makeRequest(); + static async getAccessToken(guild: Guild): Promise { + Authentication.startServer(); + + const refreshToken: string | undefined = + await Authentication.getRefreshToken(guild.id); + if (refreshToken === undefined) throw new Error('No refresh token'); + let req: MaybeTokenResponse = undefined; + while (req === undefined) + req = await Authentication.makeRequest(refreshToken).catch(console.error); + + return Authentication.storeToken(req, guild); } /** * Creates a request to get a new access token, either via a refresh token or a code - * @param data The code and redirect URI. The redirect uri must match the URI with which the code request was made. If undefined, the function will use the refresh token - * @returns A promise that resolves after the request is made - * @example Authentication.makeRequest({ code: '', redirect: 'http://localhost:3000' }) + * @param codeOrRefreshToken The code. If redirect is undefined, this acts as a refresh token instead + * @param redirect The redirect URI. Must match the URI with which the code request was made. If undefined, the function will use the first parameter as refresh token + * @returns A promise for the token response + * @example Authentication.makeRequest('', 'http://localhost:3000'); + * @example Authentication.makeRequest(''); */ - private static async makeRequest(data?: { - code: string; - redirect: string; - }): Promise { + private static async makeRequest( + codeOrRefreshToken: string, + redirect?: string + ): Promise { const params = new URLSearchParams(); params.append('client_id', DiscordClient._client.application!.id); params.append('client_secret', process.env.OAUTH_TOKEN!); - // When data is set, the request is made with a code - if (data !== undefined) { + // Make request using code + if (redirect !== undefined) { params.append('grant_type', 'authorization_code'); - params.append('code', data.code); - params.append('redirect_uri', data.redirect); + params.append('code', codeOrRefreshToken); + params.append('redirect_uri', redirect); } else { params.append('grant_type', 'refresh_token'); - params.append('refresh_token', process.env.DISCORD_REFRESH_TOKEN!); + params.append('refresh_token', codeOrRefreshToken); } + const reqObj: RequestOptions = { host: 'discord.com', path: '/api/v10/oauth2/token', @@ -100,7 +148,7 @@ export default class Authentication { } // Authorization is not required for the request when using the refresh_token - if (data !== undefined) + if (redirect !== undefined) reqObj.headers!.Authorization = `Basic ${Buffer.from( `${DiscordClient._client.application?.id}:${process.env.DISCORD_TOKEN}` ).toString('base64')}`; @@ -109,37 +157,54 @@ export default class Authentication { while (req === undefined) req = await request(reqObj, params.toString()).catch(console.error); - const json: TokenResponse | { error: string } = JSON.parse(req); + return JSON.parse(req); + } + + public static async getRefreshToken( + guildId: string + ): Promise { + Authentication.startServer(); + + const settings: GuildInfo = await GuildSettings.settings(guildId); + return settings.refreshToken !== '' ? settings.refreshToken : undefined; + } + + public static async deleteRefreshToken(guild: Guild): Promise { + const settings: GuildInfo = await GuildSettings.settings(guild.id); + settings.refreshToken = ''; + await GuildSettings.saveSettings(guild, settings).catch(console.error); + } + + public static async storeToken( + json: TokenResponse | { error: string }, + guild: Guild + ): Promise { if ('error' in json) { // If the refresh token is invalid, delete it and reject the promise - if (json.error === 'invalid_grant' && data === undefined) { - process.env.DISCORD_REFRESH_TOKEN = undefined; - // Delete line of .env that says DISCORD_REFRESH_TOKEN - fs.writeFileSync( - '.env', - fs - .readFileSync('.env', 'utf8') - .replace(/\n?DISCORD_REFRESH_TOKEN=.*/, '') - ); - } + if (json.error === 'invalid_grant') + Authentication.deleteRefreshToken(guild); return Promise.reject(new Error(json.error)); } - // If the refresh token is not set, add it to the .env file - if (process.env.DISCORD_REFRESH_TOKEN === undefined) { - fs.appendFile( - './.env', - `\nDISCORD_REFRESH_TOKEN=${json.refresh_token}`, - (err: NodeJS.ErrnoException | null): void => { - if (err) console.error(err); - } - ); - // Also add the refresh token to the process.env for the current runtime - process.env.DISCORD_REFRESH_TOKEN = json.refresh_token; - } - // Set the access token - Common._discordAccessToken = { + + const settings: GuildInfo = await GuildSettings.settings(guild.id); + settings.refreshToken = json.refresh_token; + await GuildSettings.saveSettings(guild, settings).catch(console.error); + + Common._discordAccessTokens[guild.id] = { access_token: json.access_token, expires_at: Date.now() + json.expires_in * 1000, }; } + public static createURL(guildId: string): string { + return `https://discord.com/oauth2/authorize?client_id=${DiscordClient._client.application?.id}&scope=applications.commands.permissions.update&response_type=code&state=${guildId}&redirect_uri=`; + } + + public static removeListener(guildId: string): void { + this.#listeners = this.#listeners.filter( + (listener: { + guildId: string; + listener: (json?: MaybeTokenResponse) => void; + }): boolean => listener.guildId !== guildId + ); + } } diff --git a/src/messages/permissions/CommandPermissions.ts b/src/messages/permissions/CommandPermissions.ts index b1ed9b1..b16e59e 100644 --- a/src/messages/permissions/CommandPermissions.ts +++ b/src/messages/permissions/CommandPermissions.ts @@ -1,6 +1,7 @@ import { ApplicationCommandPermissionData, Collection, + Guild, Permissions, Role, Snowflake, @@ -10,7 +11,7 @@ import Common from '../../common'; import DiscordClient from '../../DiscordClient'; import Settings, { SettingsJSON } from '../../Settings'; import { ApplicationCommandType } from '../MessageHandler'; -import Authentication from './Authentication'; +import Authentication, { MaybeTokenResponse } from './Authentication'; import { RequestOptions } from 'https'; import GuildSettings from '../../settings/GuildSettings'; @@ -19,83 +20,101 @@ import GuildSettings from '../../settings/GuildSettings'; */ export default class CommandPermissions { #commands?: Collection; + #guild: Guild; + + constructor(guild: Guild) { + this.#guild = guild; + } /** * Is to be called after commands have been set * @param commands The commands to set permissions for */ - onCommandsSet(commands: Collection): void { + async onCommandsSet( + commands: Collection + ): Promise { this.#commands = commands; - if (process.env.DISCORD_REFRESH_TOKEN !== undefined) { - if (!Common.accessTokenValid()) - Authentication.getAccessToken() + + const refreshToken = await Authentication.getRefreshToken(this.#guild.id); + + if (refreshToken !== undefined) { + if (!Common.accessTokenValid(this.#guild.id)) + Authentication.getAccessToken(this.#guild) .then(this.setPermissions.bind(this)) .catch((err: Error): void => { if (err.message !== 'invalid_grant') throw new Error(err.message); - new Authentication(this.setPermissions.bind(this)); + + Authentication.startServer(); + Authentication.addListener( + this.#guild.id, + this.setPermissions.bind(this) + ); }); else this.setPermissions(); return; } - if (Settings.settings['logging'] !== 'errors') - new Authentication(this.setPermissions.bind(this)); + Authentication.startServer(); + Authentication.addListener(this.#guild.id, this.setPermissions.bind(this)); } /** * Set permissions for commands, is used as callback for when the user has been authorized and an access token is available */ - private async setPermissions(): Promise { + private async setPermissions(json?: MaybeTokenResponse): Promise { + Authentication.removeListener(this.#guild.id); + if (json !== undefined) await Authentication.storeToken(json, this.#guild); + for (const command of this.#commands!.toJSON().filter( (command: ApplicationCommandType): boolean => command.defaultMemberPermissions?.bitfield === Permissions.FLAGS.MANAGE_GUILD )) { - for (const guild of DiscordClient._client.guilds.cache.toJSON()) { - const body: { permissions: ApplicationCommandPermissionData[] } = { - permissions: ( - await GuildSettings.settings(guild.id) - ).permissionRoles - .filter((roleName: string): boolean => - guild.roles.cache.some( - (role: Role): boolean => role.name === roleName - ) + const body: { permissions: ApplicationCommandPermissionData[] } = { + permissions: ( + await GuildSettings.settings(this.#guild.id) + ).permissionRoles + .filter((roleId: string): boolean => + this.#guild!.roles.cache.some( + (role: Role): boolean => role.id === roleId ) - .map( - (roleName: string): ApplicationCommandPermissionData => ({ - id: guild.roles.cache.find( - (role: Role): boolean => role.name === roleName - )!.id, - type: 1, // Role, see https://discord.com/developers/docs/interactions/application-commands#application-command-permissions-object-application-command-permissions-structure - permission: true, - }) - ), - }; + ) + .map( + (roleId: string): ApplicationCommandPermissionData => ({ + id: this.#guild.roles.cache.find( + (role: Role): boolean => role.id === roleId + )!.id, + type: 1, // Role, see https://discord.com/developers/docs/interactions/application-commands#application-command-permissions-object-application-command-permissions-structure + permission: true, + }) + ), + }; - const reqObj: RequestOptions = { - host: 'discord.com', - path: `/api/v10/applications/${DiscordClient._client.application?.id}/guilds/${guild.id}/commands/${command.id}/permissions`, - method: 'PUT', - headers: { - Authorization: `Bearer ${Common._discordAccessToken!.access_token}`, - 'Content-Type': 'application/json', - }, - }; + const reqObj: RequestOptions = { + host: 'discord.com', + path: `/api/v10/applications/${ + DiscordClient._client.application?.id + }/guilds/${this.#guild.id}/commands/${command.id}/permissions`, + method: 'PUT', + headers: { + Authorization: `Bearer ${ + Common._discordAccessTokens[this.#guild.id]!.access_token + }`, + 'Content-Type': 'application/json', + }, + }; - const tempSettings: SettingsJSON = Settings.settings; + const tempSettings: SettingsJSON = Settings.settings; - if (tempSettings.proxy !== undefined) { - reqObj.host = tempSettings.proxy.host; - reqObj.port = tempSettings.proxy.port; - reqObj.path = `https://discord.com${reqObj.path}`; - reqObj.headers!.Host = 'discord.com'; - } - - let req: string | void = undefined; - while (req === undefined) - req = await request(reqObj, JSON.stringify(body)).catch( - console.error - ); + if (tempSettings.proxy !== undefined) { + reqObj.host = tempSettings.proxy.host; + reqObj.port = tempSettings.proxy.port; + reqObj.path = `https://discord.com${reqObj.path}`; + reqObj.headers!.Host = 'discord.com'; } + + let req: string | void = undefined; + while (req === undefined) + req = await request(reqObj, JSON.stringify(body)).catch(console.error); } } } diff --git a/src/settings/SettingsDB.ts b/src/settings/SettingsDB.ts index 9e81608..65b7c90 100644 --- a/src/settings/SettingsDB.ts +++ b/src/settings/SettingsDB.ts @@ -10,6 +10,7 @@ export interface GuildInfo { permissionRoles: string[]; roles: RolesField[]; streamers: string[]; + refreshToken: string; } export interface CachedGuildInfo { @@ -22,6 +23,7 @@ export interface DatabaseGuildInfo { permissionRoles: string; roles: string; streamers: string; + refreshToken: string; } /** @@ -64,12 +66,14 @@ export default class SettingsDB extends DatabaseHelper { permissionRoles: '[]', roles: '[]', streamers: '[]', + refreshToken: '', }; else row = rows[0]; const serverInfo: GuildInfo = { permissionRoles: JSON.parse(row.permissionRoles), roles: JSON.parse(row.roles), streamers: JSON.parse(row.streamers), + refreshToken: row.refreshToken, }; this.#cache.push({ id, info: serverInfo }); resolve(serverInfo); @@ -100,6 +104,7 @@ export default class SettingsDB extends DatabaseHelper { permissionRoles: JSON.stringify(data.permissionRoles), roles: JSON.stringify(data.roles), streamers: JSON.stringify(data.streamers), + refreshToken: data.refreshToken, }; return this.upsert('server', mappedData); @@ -115,6 +120,7 @@ export default class SettingsDB extends DatabaseHelper { 'permissionRoles TEXT', 'roles TEXT', 'streamers TEXT', + 'refreshToken TEXT', ]); } }