Skip to content

Commit

Permalink
Alternative login flow (magic link via bot) (#1488)
Browse files Browse the repository at this point in the history
* Log in link creation initial

* Add global name to update all command

* Remove left over log

* Login command

* Update command

* Add todos

* TODOs

* Migration file fix order
  • Loading branch information
Sendouc authored Sep 9, 2023
1 parent 7db5a39 commit 4eaeb48
Show file tree
Hide file tree
Showing 44 changed files with 539 additions and 420 deletions.
7 changes: 5 additions & 2 deletions app/components/layout/LogInButtonContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useSearchParams } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import { LOG_IN_URL } from "~/utils/urls";
import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls";
import { Button } from "../Button";
import { Dialog } from "../Dialog";

Expand Down Expand Up @@ -51,7 +51,10 @@ function AuthenticationErrorHelp({ errorCode }: { errorCode: string }) {
return (
<>
<h2 className="text-lg text-center">{t("auth.errors.failed")}</h2>
{t("auth.errors.unknown")}
{t("auth.errors.unknown")}{" "}
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
{SENDOU_INK_DISCORD_URL}
</a>
</>
);
}
Expand Down
15 changes: 15 additions & 0 deletions app/db/models/users/queries.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import updateByDiscordIdSql from "./updateByDiscordId.sql";
import updateDiscordIdSql from "./updateDiscordId.sql";
import updateProfileSql from "./updateProfile.sql";
import upsertSql from "./upsert.sql";
import upsertLiteSql from "./upsertLite.sql";
import addUserWeaponSql from "./addUserWeapon.sql";
import deleteUserWeaponsSql from "./deleteUserWeapons.sql";
import wipePlusTiersSql from "./wipePlusTiers.sql";
Expand Down Expand Up @@ -54,6 +55,20 @@ export function upsert(
return upsertStm.get(input) as User;
}

const upsertLiteStm = sql.prepare(upsertLiteSql);
export function upsertLite(
input: Pick<
User,
| "discordId"
| "discordName"
| "discordDiscriminator"
| "discordAvatar"
| "discordUniqueName"
>,
) {
return upsertLiteStm.get(input) as User;
}

const updateProfileStm = sql.prepare(updateProfileSql);
const addUserWeaponStm = sql.prepare(addUserWeaponSql);
const deleteUserWeaponsStm = sql.prepare(deleteUserWeaponsSql);
Expand Down
22 changes: 22 additions & 0 deletions app/db/models/users/upsertLite.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
insert into
"User" (
"discordId",
"discordName",
"discordDiscriminator",
"discordAvatar",
"discordUniqueName"
)
values
(
@discordId,
@discordName,
@discordDiscriminator,
@discordAvatar,
@discordUniqueName
) on conflict("discordId") do
update
set
"discordName" = excluded."discordName",
"discordDiscriminator" = excluded."discordDiscriminator",
"discordAvatar" = excluded."discordAvatar",
"discordUniqueName" = excluded."discordUniqueName" returning *
6 changes: 6 additions & 0 deletions app/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface UserWeapon {
isFavorite: number;
}

export interface LogInLink {
code: string;
expiresAt: number;
userId: number;
}

export interface PlusSuggestion {
id: number;
text: string;
Expand Down
5 changes: 5 additions & 0 deletions app/features/info/routes/support.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const PERKS = [
name: "privateDiscord",
extraInfo: true,
},
{
tier: 2,
name: "prioritySupport",
extraInfo: true,
},
{
tier: 2,
name: "customizedColorsUser",
Expand Down
2 changes: 2 additions & 0 deletions app/modules/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export {
stopImpersonatingAction,
logInAction,
logOutAction,
createLogInLinkAction,
logInViaLinkLoader,
} from "./routes.server";

export { getUser, requireUser } from "./user.server";
Expand Down
30 changes: 30 additions & 0 deletions app/modules/auth/queries/createLogInLink.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { nanoid } from "nanoid";
import { sql } from "~/db/sql";
import type { LogInLink } from "~/db/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";

const stm = sql.prepare(/* sql */ `
insert into "LogInLink" (
"userId",
"expiresAt",
"code"
) values (
@userId,
@expiresAt,
@code
) returning *
`);

// 10 minutes
const LOG_IN_LINK_VALID_FOR = 10 * 60 * 1000;
const LOG_IN_LINK_LENGTH = 12;

export function createLogInLink(userId: number) {
return stm.get({
userId,
expiresAt: dateToDatabaseTimestamp(
new Date(Date.now() + LOG_IN_LINK_VALID_FOR),
),
code: nanoid(LOG_IN_LINK_LENGTH),
}) as LogInLink;
}
10 changes: 10 additions & 0 deletions app/modules/auth/queries/deleteLogInLinkByCode.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { sql } from "~/db/sql";

const stm = sql.prepare(/* sql */ `
delete from "LogInLink"
where "code" = @code
`);

export function deleteLogInLinkByCode(code: string) {
return stm.run({ code });
}
18 changes: 18 additions & 0 deletions app/modules/auth/queries/userIdByLogInLinkCode.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { sql } from "~/db/sql";
import { dateToDatabaseTimestamp } from "~/utils/dates";

const stm = sql.prepare(/* sql */ `
select "userId"
from "LogInLink"
where "code" = @code
and "expiresAt" > @now
`);

export function userIdByLogInLinkCode(code: string) {
return (
stm.get({
code,
now: dateToDatabaseTimestamp(new Date()),
}) as any
)?.userId as number | undefined;
}
83 changes: 81 additions & 2 deletions app/modules/auth/routes.server.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { canPerformAdminActions } from "~/permissions";
import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions";
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
import {
authenticator,
DISCORD_AUTH_KEY,
IMPERSONATED_SESSION_KEY,
SESSION_KEY,
} from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import { getUserId } from "./user.server";
import { validate } from "~/utils/remix";
import { parseSearchParams, validate } from "~/utils/remix";
import { z } from "zod";
import { createLogInLink } from "./queries/createLogInLink.server";
import { userIdByLogInLinkCode } from "./queries/userIdByLogInLinkCode.server";
import { deleteLogInLinkByCode } from "./queries/deleteLogInLinkByCode.server";
import { db } from "~/db";

const throwOnAuthErrors = process.env["THROW_ON_AUTH_ERROR"] === "true";

Expand Down Expand Up @@ -80,3 +86,76 @@ export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};

// below is alternative log-in flow that is operated via the Lohi Discord bot
// this is intended primarily as a workaround when website is having problems communicating
// with the Discord due to rate limits or other reasons

// only light validation here as we generally trust Lohi
const createLogInLinkActionSchema = z.object({
discordId: z.string(),
discordAvatar: z.string(),
discordName: z.string(),
discordUniqueName: z.string(),
updateOnly: z.enum(["true", "false"]),
});

export const createLogInLinkAction: ActionFunction = ({ request }) => {
const data = parseSearchParams({
request,
schema: createLogInLinkActionSchema,
});

if (!canAccessLohiEndpoint(request)) {
throw new Response(null, { status: 403 });
}

const user = db.users.upsertLite({
discordAvatar: data.discordAvatar,
discordDiscriminator: "0",
discordId: data.discordId,
discordName: data.discordName,
discordUniqueName: data.discordUniqueName,
});

if (data.updateOnly === "true") return null;

const createdLink = createLogInLink(user.id);

return {
code: createdLink.code,
};
};

const logInViaLinkActionSchema = z.object({
code: z.string(),
});

export const logInViaLinkLoader: LoaderFunction = async ({ request }) => {
const data = parseSearchParams({
request,
schema: logInViaLinkActionSchema,
});
const user = await getUserId(request);

if (user) {
throw redirect("/");
}

const userId = userIdByLogInLinkCode(data.code);
if (!userId) {
throw new Response("Invalid log in link", { status: 400 });
}

const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);

session.set(SESSION_KEY, userId);

deleteLogInLinkByCode(data.code);

throw redirect("/", {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};
1 change: 1 addition & 0 deletions app/routes/auth/create-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createLogInLinkAction as action } from "~/modules/auth";
1 change: 1 addition & 0 deletions app/routes/auth/login.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { logInViaLinkLoader as loader } from "~/modules/auth";
3 changes: 3 additions & 0 deletions discord-bot/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { plusCommand } from "./plus";
import { updateAllCommand } from "./updateall";
import { pingRolesCommand } from "./pings";
import { colorCommand } from "./color";
import { loginCommand, updateProfileCommand } from "./login";
import type { BotCommand } from "../types";

export const commands = [
Expand All @@ -15,6 +16,8 @@ export const commands = [
updateAllCommand,
pingRolesCommand,
colorCommand,
loginCommand,
updateProfileCommand,
];

export const commandsMap = Object.fromEntries(commands.map((c) => [c.name, c]));
Expand Down
96 changes: 96 additions & 0 deletions discord-bot/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import type { CommandInteraction, User } from "discord.js";
import ids from "../ids";
import type { BotCommand } from "../types";
import { sendouInkFetch } from "../utils";

const LOGIN_COMMAND_NAME = "login";
const UPDATE_PROFILE_COMMAND_NAME = "update-profile";

export const loginCommand: BotCommand = {
guilds: [ids.guilds.plusServer, ids.guilds.sendou],
name: LOGIN_COMMAND_NAME,
builder: new SlashCommandBuilder()
.setName(LOGIN_COMMAND_NAME)
.setDescription("Get log in link for sendou.ink"),
execute: async ({ interaction }) => {
await execute(interaction, false);
},
};

export const updateProfileCommand: BotCommand = {
guilds: [ids.guilds.plusServer, ids.guilds.sendou],
name: UPDATE_PROFILE_COMMAND_NAME,
builder: new SlashCommandBuilder()
.setName(UPDATE_PROFILE_COMMAND_NAME)
.setDescription("Update your username and profile picture on sendou.ink"),
execute: async ({ interaction }) => {
await execute(interaction, true);
},
};

async function execute(
interaction: CommandInteraction<any>,
updateOnly: boolean,
) {
const user = interaction.member?.user as User;
if (!user) {
return interaction.reply({
content: "Something went wrong",
});
}

const hasUniqueUsername = user.discriminator === "0";

const discordName = hasUniqueUsername ? user.globalName : user.username;
const discordUniqueName = hasUniqueUsername ? user.username : null;
if (!discordName || !discordUniqueName) {
return interaction.reply({
content:
"Can't do this with an account that is missing the new kind of Discord username",
});
}

const searchParams = new URLSearchParams(
user.avatar
? {
discordAvatar: user.avatar,
discordId: user.id,
discordName,
discordUniqueName,
updateOnly: String(updateOnly),
}
: {
discordId: user.id,
discordName,
discordUniqueName,
updateOnly: String(updateOnly),
},
);

const response = await sendouInkFetch(`/auth/create-link?${searchParams}`, {
method: "post",
});

if (updateOnly) {
if (!response.ok) {
return interaction.reply({
content: "Something went wrong when updating",
ephemeral: true,
});
}

return interaction.reply({
content: "Updated your profile on sendou.ink",
ephemeral: true,
});
}

const { code } = await response.json();

return interaction.reply({
content:
"Use the link below to log in to sendou.ink. It's active for 10 minutes. ⚠️ Don't share this link with others as it will allow them to log in to your account.\n\n[log in link](https://sendou.ink/auth/login?code=${code})",
ephemeral: true,
});
}
3 changes: 1 addition & 2 deletions discord-bot/commands/updateall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ async function getUsersToUpdate(client: Client<boolean>) {

const hasUniqueUsername = user.discriminator === "0";

// TODO: global_name when discord.js supports it
userUpdates.push({
discordId: user.id,
discordAvatar: user.avatar,
discordName: hasUniqueUsername ? null : user.username,
discordName: hasUniqueUsername ? user.globalName : user.username,
discordUniqueName: hasUniqueUsername ? user.username : null,
});

Expand Down
Loading

0 comments on commit 4eaeb48

Please sign in to comment.