feat: add force log out endpoint
This commit is contained in:
parent
c18b79e570
commit
42041d49bc
7 changed files with 72 additions and 15 deletions
|
@ -1,17 +1,35 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
using Microsoft.AspNetCore.Mvc.Routing;
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
using Microsoft.AspNetCore.Routing.Template;
|
using Microsoft.AspNetCore.Routing.Template;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/internal")]
|
[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<InternalController>();
|
||||||
|
|
||||||
|
[HttpPost("force-log-out")]
|
||||||
|
[Authenticate]
|
||||||
|
[Authorize("identify")]
|
||||||
|
public async Task<IActionResult> 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+\})")]
|
[GeneratedRegex(@"(\{\w+\})")]
|
||||||
private static partial Regex PathVarRegex();
|
private static partial Regex PathVarRegex();
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,5 @@ import "dotenv/config";
|
||||||
import { env } from "node:process";
|
import { env } from "node:process";
|
||||||
|
|
||||||
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
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";
|
export const LANGUAGE = env.LANGUAGE || "en";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
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 { ApiError, ErrorCode } from "./api/error";
|
||||||
import { tokenCookieName } from "~/lib/utils";
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
|
@ -8,14 +8,17 @@ export type RequestParams = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
body?: any;
|
body?: any;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
isInternal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function serverRequest<T>(
|
async function requestInternal(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
params: RequestParams = {},
|
params: RequestParams = {},
|
||||||
) {
|
): Promise<Response> {
|
||||||
const url = `${API_BASE}/v2${path}`;
|
const base = params.isInternal ? INTERNAL_API_BASE : API_BASE + "/v2";
|
||||||
|
|
||||||
|
const url = `${base}${path}`;
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||||
|
@ -37,6 +40,19 @@ export default async function serverRequest<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as ApiError;
|
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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
params: RequestParams = {},
|
||||||
|
) {
|
||||||
|
const resp = await requestInternal(method, path, params);
|
||||||
return (await resp.json()) as T;
|
return (await resp.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, Form, InputGroup, Table } from "react-bootstrap";
|
import { Button, Form, InputGroup, Table } from "react-bootstrap";
|
||||||
import { useTranslation } from "react-i18next";
|
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 settingsLoader } from "../settings/route";
|
||||||
import { loader as rootLoader } from "../../root";
|
import { loader as rootLoader } from "../../root";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
@ -43,6 +43,7 @@ export default function SettingsIndex() {
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
|
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
const createdAt = idTimestamp(user.id);
|
const createdAt = idTimestamp(user.id);
|
||||||
|
|
||||||
|
@ -55,13 +56,7 @@ export default function SettingsIndex() {
|
||||||
<Form.Group className="mb-3" controlId="username">
|
<Form.Group className="mb-3" controlId="username">
|
||||||
<Form.Label>{t("settings.general.username")}</Form.Label>
|
<Form.Label>{t("settings.general.username")}</Form.Label>
|
||||||
<InputGroup className="m-1 w-75">
|
<InputGroup className="m-1 w-75">
|
||||||
<Form.Control
|
<Form.Control defaultValue={user.username} name="username" type="text" required />
|
||||||
defaultValue={user.username}
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" type="submit">
|
<Button variant="secondary" type="submit">
|
||||||
{t("settings.general.change-username")}
|
{t("settings.general.change-username")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -85,9 +80,14 @@ export default function SettingsIndex() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t("settings.general.log-out-everywhere")}</h4>
|
<h4>{t("settings.general.log-out-everywhere")}</h4>
|
||||||
<p></p>
|
<p>{t("settings.general.log-out-everywhere-hint")}</p>
|
||||||
|
<fetcher.Form method="POST" action="/settings/force-log-out">
|
||||||
|
<Button type="submit" variant="danger">
|
||||||
|
{t("settings.general.force-log-out-button")}
|
||||||
|
</Button>
|
||||||
|
</fetcher.Form>
|
||||||
</div>
|
</div>
|
||||||
<h4>{t("settings.general.table-header")}</h4>
|
<h4 className="mt-2">{t("settings.general.table-header")}</h4>
|
||||||
<Table striped bordered hover>
|
<Table striped bordered hover>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -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) },
|
||||||
|
});
|
||||||
|
};
|
|
@ -96,6 +96,8 @@
|
||||||
"change-username": "Change username",
|
"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.",
|
"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": "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",
|
"table-header": "General account information",
|
||||||
"id": "Your user ID",
|
"id": "Your user ID",
|
||||||
"created": "Account created at",
|
"created": "Account created at",
|
||||||
|
|
|
@ -18,6 +18,7 @@ services:
|
||||||
build: ./Foxnouns.Frontend
|
build: ./Foxnouns.Frontend
|
||||||
environment:
|
environment:
|
||||||
- "API_BASE=http://rate:5003/api"
|
- "API_BASE=http://rate:5003/api"
|
||||||
|
- "INTERNAL_API_BASE=http://backend:5000/api"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/frontend.env:/app/.env
|
- ./docker/frontend.env:/app/.env
|
||||||
|
|
Loading…
Reference in a new issue