diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index b79de1c..2048f59 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -1,17 +1,35 @@ using System.Text.RegularExpressions; using Foxnouns.Backend.Database; +using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing.Template; +using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Controllers; [ApiController] [Route("/api/internal")] -public partial class InternalController(DatabaseContext db) : ControllerBase +public partial class InternalController(ILogger logger, DatabaseContext db) : ControllerBase { + private readonly ILogger _logger = logger.ForContext(); + + [HttpPost("force-log-out")] + [Authenticate] + [Authorize("identify")] + public async Task ForceLogoutAsync() + { + var user = HttpContext.GetUser()!; + + _logger.Information("Invalidating all tokens for user {UserId}", user.Id); + await db.Tokens.Where(t => t.UserId == user.Id) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true)); + + return NoContent(); + } + [GeneratedRegex(@"(\{\w+\})")] private static partial Regex PathVarRegex(); diff --git a/Foxnouns.Frontend/app/env.server.ts b/Foxnouns.Frontend/app/env.server.ts index 2add747..5e5e84b 100644 --- a/Foxnouns.Frontend/app/env.server.ts +++ b/Foxnouns.Frontend/app/env.server.ts @@ -2,4 +2,5 @@ import "dotenv/config"; import { env } from "node:process"; export const API_BASE = env.API_BASE || "https://pronouns.localhost/api"; +export const INTERNAL_API_BASE = env.INTERNAL_API_BASE || "https://localhost:5000/api"; export const LANGUAGE = env.LANGUAGE || "en"; diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index 4648d5f..7777422 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -1,5 +1,5 @@ import { parse as parseCookie, serialize as serializeCookie } from "cookie"; -import { API_BASE } from "~/env.server"; +import { API_BASE, INTERNAL_API_BASE } from "~/env.server"; import { ApiError, ErrorCode } from "./api/error"; import { tokenCookieName } from "~/lib/utils"; @@ -8,14 +8,17 @@ export type RequestParams = { // eslint-disable-next-line @typescript-eslint/no-explicit-any body?: any; headers?: Record; + isInternal?: boolean; }; -export default async function serverRequest( +async function requestInternal( method: string, path: string, params: RequestParams = {}, -) { - const url = `${API_BASE}/v2${path}`; +): Promise { + const base = params.isInternal ? INTERNAL_API_BASE : API_BASE + "/v2"; + + const url = `${base}${path}`; const resp = await fetch(url, { method, body: params.body ? JSON.stringify(params.body) : undefined, @@ -37,6 +40,19 @@ export default async function serverRequest( } if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError; + return resp; +} + +export async function fastRequest(method: string, path: string, params: RequestParams = {}) { + await requestInternal(method, path, params); +} + +export default async function serverRequest( + method: string, + path: string, + params: RequestParams = {}, +) { + const resp = await requestInternal(method, path, params); return (await resp.json()) as T; } diff --git a/Foxnouns.Frontend/app/routes/settings._index/route.tsx b/Foxnouns.Frontend/app/routes/settings._index/route.tsx index d575108..5b90851 100644 --- a/Foxnouns.Frontend/app/routes/settings._index/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings._index/route.tsx @@ -1,6 +1,6 @@ import { Button, Form, InputGroup, Table } from "react-bootstrap"; import { useTranslation } from "react-i18next"; -import { Form as RemixForm, useActionData, useRouteLoaderData } from "@remix-run/react"; +import { Form as RemixForm, useActionData, useFetcher, useRouteLoaderData } from "@remix-run/react"; import { loader as settingsLoader } from "../settings/route"; import { loader as rootLoader } from "../../root"; import { DateTime } from "luxon"; @@ -43,6 +43,7 @@ export default function SettingsIndex() { const actionData = useActionData(); const { meta } = useRouteLoaderData("root")!; const { t } = useTranslation(); + const fetcher = useFetcher(); const createdAt = idTimestamp(user.id); @@ -55,13 +56,7 @@ export default function SettingsIndex() { {t("settings.general.username")} - + @@ -85,9 +80,14 @@ export default function SettingsIndex() {

{t("settings.general.log-out-everywhere")}

-

+

{t("settings.general.log-out-everywhere-hint")}

+ + +
-

{t("settings.general.table-header")}

+

{t("settings.general.table-header")}

diff --git a/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx b/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx new file mode 100644 index 0000000..caa9d4a --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.force-log-out/route.tsx @@ -0,0 +1,19 @@ +import { ActionFunction, redirect } from "@remix-run/node"; +import { fastRequest, getToken, writeCookie } from "~/lib/request.server"; +import { tokenCookieName } from "~/lib/utils"; + +export const action: ActionFunction = async ({ request }) => { + const token = getToken(request); + if (!token) + return redirect("/", { + status: 303, + headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) }, + }); + + await fastRequest("POST", "/internal/force-log-out", { token, isInternal: true }); + + return redirect("/", { + status: 303, + headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) }, + }); +}; diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 279173b..c35a1d7 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -96,6 +96,8 @@ "change-username": "Change username", "username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.", "log-out-everywhere": "Log out everywhere", + "log-out-everywhere-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.", + "force-log-out-button": "Force log out", "table-header": "General account information", "id": "Your user ID", "created": "Account created at", diff --git a/docker-compose.yml b/docker-compose.yml index 7176fc2..6fafd18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: build: ./Foxnouns.Frontend environment: - "API_BASE=http://rate:5003/api" + - "INTERNAL_API_BASE=http://backend:5000/api" restart: unless-stopped volumes: - ./docker/frontend.env:/app/.env