diff --git a/.env.example b/.env.example index 5a977bb2..59d90f07 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,16 @@ # DISCORD_VOICE_CHANNELS=3 # DISCORD_BOT_NAME=CTFNote +# Enable this if you want users to be able to make accounts through the /register command in discord +# DISCORD_REGISTRATION_ENABLED=false + +# Which role the user should be granted on the ctfnote when creating a account through the bot +# DISCORD_REGISTRATION_CTFNOTE_ROLE=user_guest + +# If you want the bot to verify if a user has a specific role in the discord before allowing them to make a account through +# the Discord command, set the ID of the role below, else leave this field empty. +#DISCORD_REGISTRATION_ROLE_ID=discord_id + # Configure timezone and locale # TZ=Europe/Paris # LC_ALL=en_US.UTF-8 diff --git a/api/migrations/55-discord-account-invitation-link.sql b/api/migrations/55-discord-account-invitation-link.sql new file mode 100644 index 00000000..dff46b91 --- /dev/null +++ b/api/migrations/55-discord-account-invitation-link.sql @@ -0,0 +1,104 @@ +ALTER TABLE ctfnote_private.invitation_link + ADD COLUMN "discord_id" TEXT UNIQUE DEFAULT NULL; + +DROP FUNCTION ctfnote.create_invitation_link ("role" ctfnote.role); +CREATE OR REPLACE FUNCTION ctfnote.create_invitation_link ("role" ctfnote.role, "discord_id" text default null) + RETURNS ctfnote.invitation_link_response + AS $$ +DECLARE + invitation_link ctfnote_private.invitation_link; +BEGIN + INSERT INTO ctfnote_private.invitation_link ("role", "token", "discord_id") + VALUES (create_invitation_link.role, gen_random_uuid (), create_invitation_link.discord_id) + RETURNING + * INTO invitation_link; + RETURN ROW (invitation_link.token::text)::ctfnote.invitation_link_response; +END; +$$ +LANGUAGE plpgsql +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION ctfnote.create_invitation_link (ctfnote.role, text) TO user_admin; + +CREATE OR REPLACE FUNCTION ctfnote.register_with_token ("token" text, "login" text, "password" text) + RETURNS ctfnote.jwt + AS $$ +DECLARE + invitation_role ctfnote.role; + invitation_discord_id text; +BEGIN + SELECT + ROLE, discord_id INTO invitation_role, invitation_discord_id + FROM + ctfnote_private.invitation_link + WHERE + invitation_link.token::text = register_with_token.token + AND expiration > now(); + IF invitation_role IS NOT NULL THEN + DELETE FROM ctfnote_private.invitation_link + WHERE invitation_link.token::text = register_with_token.token; + IF invitation_discord_id IS NOT NULL THEN + RETURN ctfnote_private.do_register (register_with_token.login, register_with_token.password, invitation_role, invitation_discord_id); + ELSE + RETURN ctfnote_private.do_register (register_with_token.login, register_with_token.password, invitation_role); + END IF; + ELSE + RAISE EXCEPTION 'Invalid token'; + END IF; +END +$$ +LANGUAGE plpgsql +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION ctfnote.register_with_token (text, text, text) TO user_anonymous; + +-- first we remove and re-apply the old internal registration function to be extra verbose +-- we implement the additional logic for registration with discord_id in a seperate function with the same name, thus overloading this function for normal original operation and +-- operation with the new discord id linking. +DROP FUNCTION ctfnote_private.do_register ("login" text, "password" text, "role" ctfnote.role); + +CREATE OR REPLACE FUNCTION ctfnote_private.do_register ("login" text, "password" text, "role" ctfnote.role) + RETURNS ctfnote.jwt + AS $$ +DECLARE + new_user ctfnote_private.user; +BEGIN + INSERT INTO ctfnote_private.user ("login", "password", "role") + VALUES (do_register.login, crypt(do_register.password, gen_salt('bf')), do_register.role) + RETURNING + * INTO new_user; + INSERT INTO ctfnote.profile ("id", "username") + VALUES (new_user.id, do_register.login); + RETURN (ctfnote_private.new_token (new_user.id))::ctfnote.jwt; +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'Username already taken'; +END; +$$ +LANGUAGE plpgsql +STRICT +SECURITY DEFINER; + +-- overloaded function, implements the logic needed for discord linking. +CREATE OR REPLACE FUNCTION ctfnote_private.do_register ("login" text, "password" text, "role" ctfnote.role, "discord_id" text) + RETURNS ctfnote.jwt + AS $$ +DECLARE + new_user ctfnote_private.user; +BEGIN + INSERT INTO ctfnote_private.user ("login", "password", "role") + VALUES (do_register.login, crypt(do_register.password, gen_salt('bf')), do_register.role) + RETURNING + * INTO new_user; + INSERT INTO ctfnote.profile ("id", "username", "discord_id") + VALUES (new_user.id, do_register.login, do_register.discord_id); + RETURN (ctfnote_private.new_token (new_user.id))::ctfnote.jwt; +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'Username already taken'; +END; +$$ +LANGUAGE plpgsql +STRICT +SECURITY DEFINER; + diff --git a/api/src/config.ts b/api/src/config.ts index 4076db41..353a6906 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -42,6 +42,9 @@ export type CTFNoteConfig = DeepReadOnly<{ voiceChannels: number; botName: string; maxChannelsPerCategory: number; + registrationEnabled: string; + registrationAccountRole: string; + registrationRoleId: string; }; }>; @@ -92,7 +95,13 @@ const config: CTFNoteConfig = { serverId: getEnv("DISCORD_SERVER_ID"), voiceChannels: getEnvInt("DISCORD_VOICE_CHANNELS"), botName: getEnv("DISCORD_BOT_NAME", "CTFNote"), - maxChannelsPerCategory: 50, // 50 is the hard Discord limit + maxChannelsPerCategory: 50, //! 50 is the hard Discord limit + registrationEnabled: getEnv("DISCORD_REGISTRATION_ENABLED", "false"), + registrationAccountRole: getEnv( + "DISCORD_REGISTRATION_CTFNOTE_ROLE", + "user_guest" + ), + registrationRoleId: getEnv("DISCORD_REGISTRATION_ROLE_ID", ""), }, }; diff --git a/api/src/discord/commands.ts b/api/src/discord/commands.ts index 7e729ac6..18c9705b 100644 --- a/api/src/discord/commands.ts +++ b/api/src/discord/commands.ts @@ -5,6 +5,7 @@ import { SolveTask } from "./commands/solveTask"; import { LinkUser } from "./commands/linkUser"; import { StartWorking, StopWorking } from "./commands/workingOn"; import { DeleteCtf } from "./commands/deleteCtf"; +import { Register } from "./commands/register"; export const Commands: Command[] = [ ArchiveCtf, @@ -14,4 +15,5 @@ export const Commands: Command[] = [ StartWorking, StopWorking, DeleteCtf, + Register, ]; diff --git a/api/src/discord/commands/register.ts b/api/src/discord/commands/register.ts new file mode 100644 index 00000000..a6a68b91 --- /dev/null +++ b/api/src/discord/commands/register.ts @@ -0,0 +1,127 @@ +import { + ApplicationCommandType, + Client, + CommandInteraction, + GuildMemberRoleManager, +} from "discord.js"; +import { Command } from "../command"; +import { + AllowedRoles, + createInvitationTokenForDiscordId, + getInvitationTokenForDiscordId, + getUserByDiscordId, +} from "../database/users"; +import config from "../../config"; + +async function getInvitationUrl(invitationCode: string | null = null) { + if (config.pad.domain == "") return null; + if (invitationCode == null) return null; + + const ssl = config.pad.useSSL == "false" ? "" : "s"; + + return `http${ssl}://${config.pad.domain}/#/auth/register/${invitationCode}`; +} + +async function getProfileUrl() { + if (config.pad.domain == "") return null; + + const ssl = config.pad.useSSL == "false" ? "" : "s"; + + return `http${ssl}://${config.pad.domain}/#/user/settings`; +} + +async function registerLogic(client: Client, interaction: CommandInteraction) { + if (config.discord.registrationEnabled.toLowerCase() !== "true") { + await interaction.editReply({ + content: + "The functionality to create your own account this way has been disabled by an administrator.", + }); + return; + } + + if (config.discord.registrationRoleId !== "") { + if ( + !(interaction.member?.roles as GuildMemberRoleManager).cache.has( + config.discord.registrationRoleId + ) + ) { + await interaction.editReply({ + content: + "You do not have the role required to create an account yourself.", + }); + return; + } + } + + const userId = await getUserByDiscordId(interaction.user.id); + if (userId != null) { + await interaction.editReply({ + content: + "You can't link the same Discord account twice! If you do not have a CTFNote account or haven't linked it, contact an administrator.", + }); + return; + } + + const existingInvitationCode = await getInvitationTokenForDiscordId( + interaction.user.id + ); + if (existingInvitationCode != null) { + const invitationUrl = await getInvitationUrl(existingInvitationCode); + if (invitationUrl == null) { + await interaction.editReply({ + content: + "Could not generate invitation URL. Please contact an administrator.", + }); + return; + } + + await interaction.editReply({ + content: `Your personal invitation url: ${invitationUrl}.\n-# If you already have a CTFNote account you should link it using the \`/link\` command using the Discord token from your profile: ${await getProfileUrl()}.`, + }); + return; + } + + await interaction.editReply({ + content: + "Generating a private invitation URL... If you already have a CTFNote account you should link it using the `/link` command instead.", + }); + + const invitationCode = await createInvitationTokenForDiscordId( + interaction.user.id, + (config.discord.registrationAccountRole as AllowedRoles) ?? + AllowedRoles.user_guest + ); + + if (invitationCode == null) { + await interaction.editReply({ + content: + "Could not generate an invitation code. Please contact an administrator.", + }); + return; + } + + const invitationUrl = await getInvitationUrl(invitationCode); + if (invitationUrl == null) { + await interaction.editReply({ + content: + "Could not get an invitation URL. Please contact an administrator.", + }); + return; + } + + await interaction.editReply({ + content: `Your personal invitation url: ${invitationUrl}.\n-# If you already have a CTFNote account you should link it using the \`/link\` command using the Discord token from your profile: ${await getProfileUrl()}.`, + }); + return; +} + +export const Register: Command = { + name: "register", + description: "Create an account on CTFNote (if enabled)!", + type: ApplicationCommandType.ChatInput, + run: async (client, interaction) => { + return registerLogic(client, interaction).catch((e) => { + console.error("Error during /register Discord logic: ", e); + }); + }, +}; diff --git a/api/src/discord/database/users.ts b/api/src/discord/database/users.ts index ecca8306..6a34997a 100644 --- a/api/src/discord/database/users.ts +++ b/api/src/discord/database/users.ts @@ -45,6 +45,59 @@ export async function setDiscordIdForUser( } } +// refactor above to an enum +export enum AllowedRoles { + user_guest = "user_guest", + user_friend = "user_friend", + user_member = "user_member", + user_manager = "user_manager", + user_admin = "user_admin", +} + +export async function getInvitationTokenForDiscordId( + discordId: string, + pgClient: PoolClient | null = null +): Promise { + const useRequestClient = pgClient != null; + if (pgClient == null) pgClient = await connectToDatabase(); + + try { + const query = + "SELECT token FROM ctfnote_private.invitation_link WHERE discord_id = $1"; + const values = [discordId]; + const queryResult = await pgClient.query(query, values); + + return queryResult.rows[0].token as string; + } catch (error) { + return null; + } finally { + if (!useRequestClient) pgClient.release(); + } +} + +export async function createInvitationTokenForDiscordId( + discordId: string, + role: AllowedRoles = AllowedRoles.user_guest, + pgClient: PoolClient | null = null +): Promise { + role = (role as AllowedRoles) ?? AllowedRoles.user_guest; + + const useRequestClient = pgClient != null; + if (pgClient == null) pgClient = await connectToDatabase(); + + try { + const query = "SELECT token FROM ctfnote.create_invitation_link($1, $2)"; + const values = [role, discordId]; + const queryResult = await pgClient.query(query, values); + + return queryResult.rows[0].token as string; + } catch (error) { + return null; + } finally { + if (!useRequestClient) pgClient.release(); + } +} + export async function getUserByDiscordId( discordId: string, pgClient: PoolClient | null = null @@ -106,3 +159,23 @@ export async function getDiscordUsersThatCanPlayCTF( pgClient.release(); } } + +export async function getUserIdFromUsername( + username: string, + pgClient: PoolClient | null = null +): Promise { + const useRequestClient = pgClient != null; + if (pgClient == null) pgClient = await connectToDatabase(); + + try { + const query = "SELECT id FROM ctfnote.profile WHERE username = $1"; + const values = [username]; + const queryResult = await pgClient.query(query, values); + + return queryResult.rows[0].id as bigint; + } catch (error) { + return null; + } finally { + if (!useRequestClient) pgClient.release(); + } +} diff --git a/api/src/plugins/discordHooks.ts b/api/src/plugins/discordHooks.ts index f90309bc..45571d64 100644 --- a/api/src/plugins/discordHooks.ts +++ b/api/src/plugins/discordHooks.ts @@ -9,7 +9,10 @@ import { } from "../discord/database/ctfs"; import { getDiscordGuild, usingDiscordBot } from "../discord"; import { changeDiscordUserRoleForCTF } from "../discord/commands/linkUser"; -import { getDiscordIdFromUserId } from "../discord/database/users"; +import { + getDiscordIdFromUserId, + getUserIdFromUsername, +} from "../discord/database/users"; import { Task, getTaskByCtfIdAndNameFromDatabase, @@ -94,7 +97,8 @@ const discordMutationHook = (_build: Build) => (fieldContext: Context) => { fieldContext.scope.fieldName !== "resetDiscordId" && fieldContext.scope.fieldName !== "deleteCtf" && fieldContext.scope.fieldName !== "updateUserRole" && - fieldContext.scope.fieldName !== "setDiscordEventLink" + fieldContext.scope.fieldName !== "setDiscordEventLink" && + fieldContext.scope.fieldName !== "registerWithToken" ) { return null; } @@ -279,6 +283,30 @@ const discordMutationHook = (_build: Build) => (fieldContext: Context) => { }); } + /* + * We have a nice ductape solution for the following problem: + * During the handling of these hooks, the changes to the database are not committed yet. + * This means that we can't query the database for the new user id. + * We have to wait a bit to make sure the user is in the database. + * Alternatively we can hook the postgraphile lifecycle but that is not compatible with the current setup. + * The outgoing request is probably handling within 1 second, so this works fine. + */ + if (fieldContext.scope.fieldName === "registerWithToken") { + const username = args.input.login; // the login is equal to the username at registration + setTimeout(async () => { + const userId = await getUserIdFromUsername(username, null); // use null to get a new client which is privileged as the Discord bot + if (userId == null) return; + const ctfs = await getAccessibleCTFsForUser(userId, null); + for (let i = 0; i < ctfs.length; i++) { + await changeDiscordUserRoleForCTF(userId, ctfs[i], "add").catch( + (err) => { + console.error("Error while adding role to user: ", err); + } + ); + } + }, 2000); + } + return input; }; diff --git a/front/graphql.schema.json b/front/graphql.schema.json index dd9db357..5b22861a 100644 --- a/front/graphql.schema.json +++ b/front/graphql.schema.json @@ -1297,6 +1297,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "discordId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "role", "description": null, diff --git a/front/src/generated/graphql.ts b/front/src/generated/graphql.ts index 8f374bea..4cf56778 100644 --- a/front/src/generated/graphql.ts +++ b/front/src/generated/graphql.ts @@ -300,6 +300,7 @@ export type CreateInvitationLinkInput = { * payload verbatim. May be used to track mutations by the client. */ clientMutationId?: InputMaybe; + discordId?: InputMaybe; role?: InputMaybe; };