Alternative login flow (magic link via bot) (#1488)

* 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
This commit is contained in:
Kalle
2023-09-09 10:27:59 +03:00
committed by GitHub
parent 7db5a395f9
commit 4eaeb48136
44 changed files with 539 additions and 420 deletions

View File

@@ -4,6 +4,8 @@ export {
stopImpersonatingAction,
logInAction,
logOutAction,
createLogInLinkAction,
logInViaLinkLoader,
} from "./routes.server";
export { getUser, requireUser } from "./user.server";

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -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;
}

View File

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