Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add linked account creation from the Discord bot #301

Merged
merged 18 commits into from
Aug 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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