sendou.ink/app/features/auth/core/routes.server.ts
2025-12-29 19:21:11 +02:00

189 lines
5.3 KiB
TypeScript

import { isbot } from "isbot";
import type { ActionFunction, LoaderFunction } from "react-router";
import { redirect } from "react-router";
import { z } from "zod/v4";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { logger } from "~/utils/logger";
import {
canAccessLohiEndpoint,
errorToastIfFalsy,
parseSearchParams,
} from "~/utils/remix.server";
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
import { createLogInLink } from "../queries/createLogInLink.server";
import { deleteLogInLinkByCode } from "../queries/deleteLogInLinkByCode.server";
import { userIdByLogInLinkCode } from "../queries/userIdByLogInLinkCode.server";
import {
authenticator,
IMPERSONATED_SESSION_KEY,
SESSION_KEY,
} from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import { getUserId, requireUser } from "./user.server";
export const callbackLoader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get("error") === "access_denied") {
// The user denied the authentication request
// https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/
throw redirect(authErrorUrl("aborted"));
}
try {
const userId = await authenticator.authenticate("discord", request);
const session = await authSessionStorage.getSession(
request.headers.get(SESSION_KEY),
);
session.set(SESSION_KEY, userId);
return redirect("/", {
headers: {
"Set-Cookie": await authSessionStorage.commitSession(session),
},
});
} catch (error) {
if (error instanceof Error) {
logger.error("Error during authentication:", error);
throw redirect(authErrorUrl("unknown"));
}
throw error;
}
};
export const logOutAction: ActionFunction = async ({ request }) => {
const session = await authSessionStorage.getSession(
request.headers.get(SESSION_KEY),
);
return redirect("/", {
headers: { "Set-Cookie": await authSessionStorage.destroySession(session) },
});
};
export const logInAction: ActionFunction = async ({ request }) => {
errorToastIfFalsy(
process.env.LOGIN_DISABLED !== "true",
"Login is temporarily disabled",
);
return await authenticator.authenticate("discord", request);
};
export const impersonateAction: ActionFunction = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = await requireUser(request);
requireRole(user, "ADMIN");
}
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
const url = new URL(request.url);
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
session.set(IMPERSONATED_SESSION_KEY, userId);
throw redirect(ADMIN_PAGE, {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};
export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
session.unset(IMPERSONATED_SESSION_KEY);
throw redirect(ADMIN_PAGE, {
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().nullish(),
discordName: z.string(),
discordUniqueName: z.string(),
updateOnly: z.enum(["true", "false"]),
});
export const createLogInLinkAction: ActionFunction = async ({ request }) => {
const data = parseSearchParams({
request,
schema: createLogInLinkActionSchema,
});
if (!canAccessLohiEndpoint(request)) {
throw new Response(null, { status: 403 });
}
const user = await UserRepository.upsert({
discordAvatar: data.discordAvatar ?? null,
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 }) => {
// make sure Discord link preview doesn't consume the login link
const userAgent = request.headers.get("user-agent");
if (userAgent && isbot(userAgent)) {
return null;
}
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) },
});
};