Skip to content

Commit

Permalink
Merge pull request #301 from daanbreur/300-account-creation-from-discord
Browse files Browse the repository at this point in the history
Add linked account creation from the Discord bot
  • Loading branch information
JJ-8 authored Aug 10, 2024
2 parents 87dc95e + 22cdd89 commit 7cfff57
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 3 deletions.
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions api/migrations/55-discord-account-invitation-link.sql
Original file line number Diff line number Diff line change
@@ -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;

11 changes: 10 additions & 1 deletion api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export type CTFNoteConfig = DeepReadOnly<{
voiceChannels: number;
botName: string;
maxChannelsPerCategory: number;
registrationEnabled: string;
registrationAccountRole: string;
registrationRoleId: string;
};
}>;

Expand Down Expand Up @@ -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", ""),
},
};

Expand Down
2 changes: 2 additions & 0 deletions api/src/discord/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +15,5 @@ export const Commands: Command[] = [
StartWorking,
StopWorking,
DeleteCtf,
Register,
];
127 changes: 127 additions & 0 deletions api/src/discord/commands/register.ts
Original file line number Diff line number Diff line change
@@ -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);
});
},
};
73 changes: 73 additions & 0 deletions api/src/discord/database/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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<string | null> {
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
Expand Down Expand Up @@ -106,3 +159,23 @@ export async function getDiscordUsersThatCanPlayCTF(
pgClient.release();
}
}

export async function getUserIdFromUsername(
username: string,
pgClient: PoolClient | null = null
): Promise<bigint | null> {
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();
}
}
Loading

0 comments on commit 7cfff57

Please sign in to comment.