diff --git a/backend/example.env b/backend/example.env index ca154612ea2791d3fcd996bd2f7aa3a30d0e24b0..c6c0bdad8cc794e4e65a94ae48a0bad6b46608a5 100644 --- a/backend/example.env +++ b/backend/example.env @@ -7,10 +7,6 @@ PORT=3000 OIDC_ISSUER=http://localhost:3000 -# redirect routes to this host -# used by some alternate packager, like vite -CLIENT_HOST=http://localhost:5173 - # Lemmy Polyfill LEMMY_HOST= LEMMY_USER= diff --git a/backend/src/lib/api.ts b/backend/src/lib/api.ts index 26e9075f2e1a226870072c3ddba521b5a6e5008e..2f6be2a584fc682837ff9e78304e28593780acc7 100644 --- a/backend/src/lib/api.ts +++ b/backend/src/lib/api.ts @@ -3,6 +3,7 @@ import cookieParser from "cookie-parser"; import { doesInstanceSupportOIDC, oidc } from "./oidc.js"; import { DOMAIN_REGEX, + getExpressIP, isInstanceDomainValid, makeClientPublic, } from "./utils.js"; @@ -12,6 +13,7 @@ import { prisma } from "./prisma.js"; import { ReceiveCodeProvider } from "./delivery/receive.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; +import { ShadowAPI, UserLoginError } from "./shadow.js"; const app = express.Router(); @@ -25,7 +27,8 @@ app.use(cookieParser()); * Get the current user's session if it exists */ app.get("/whoami", async (req, res) => { - const session = await oidc.Session.find(req.cookies._session); + const ctx = oidc.app.createContext(req, res); + const session = await oidc.Session.get(ctx); if (!session || !session.accountId) return res.json({ success: false, error: "no-session" }); @@ -261,6 +264,17 @@ app.post("/login/step/verify", async (req, res) => { return; } + if (process.env.BYPASS_CODE_VERIFICATION) { + console.warn("BYPASS_CODE_VERIFICATION is set; not verifying 2fa codes"); + + req.session.user = { sub: session.user_sub }; + req.session.login = undefined; + req.session.save(() => { + res.json({ success: true }); + }); + return; + } + switch (session.mode) { case "SEND_CODE": { // code has been sent, we're expecting the user to give a code @@ -367,6 +381,40 @@ app.post("/interaction/:uid/confirm", async (req, res) => { .status(400) .json({ success: false, error: "Invalid interaction" }); + if (!interaction.session?.accountId) { + return res + .status(400) + .json({ success: false, error: "Failed to get accountId" }); + } + + const ipAddress = getExpressIP(req); + + try { + await ShadowAPI.canUserLogin({ + sub: interaction.session.accountId, + ip: ipAddress, + }); + } catch (e) { + if (e instanceof UserLoginError) { + res.status(400).json({ + success: false, + error: "shadow", + metadata: { + message: e.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: "shadow", + metadata: { + message: "internal error", + }, + }); + } + return; + } + let grant; if (interaction.grantId) { grant = await oidc.Grant.find(interaction.grantId); diff --git a/backend/src/lib/express.ts b/backend/src/lib/express.ts index 4b4bd54dedb13cb343878ea2c882aa8abe2f99f8..5bf32de00e38fb6a21308a202fe6be8fba6d2fd9 100644 --- a/backend/src/lib/express.ts +++ b/backend/src/lib/express.ts @@ -34,51 +34,109 @@ app.use( secure: process.env.NODE_ENV === "production" && !process.env.USE_INSECURE, sameSite: "lax", + httpOnly: false, }, // TODO: do not use memory store }) ); -app.use("/interaction/:uid", async (req, res, next) => { - const interaction = await oidc.Interaction.find(req.params.uid); - if (interaction?.prompt.name === "login") { - if (typeof req.session.user === "undefined") { - res.redirect("/login?return=" + encodeURIComponent(req.originalUrl)); - } else { - try { - const returnTo = await oidc.interactionResult(req, res, { - login: { accountId: req.session.user.sub }, - }); - - req.session.destroy(() => { - res.redirect(returnTo); - }); - } catch (e) { - console.error("Error while in interaction middleware", e); - - req.session.destroy(() => { - if (e instanceof OIDC_Errors.SessionNotFound) { - res.send("

session lost

try logging in again"); - } else { - res.send("

unknown error

try logging in again"); - } +const interactionMiddleware = ( + req: express.Request, + resp: express.Response +) => { + return new Promise< + | { type: "continue" } + | { type: "redirect"; to: string } + | { type: "error"; error: "session_lost" } + | { type: "error"; error: "unknown" } + >(async (res) => { + const interaction = await oidc.Interaction.find(req.params.uid); + if (interaction?.prompt.name === "login") { + if (typeof req.session.user === "undefined") { + res({ + type: "redirect", + to: + "/login?return=" + + encodeURIComponent("/interaction/" + req.params.uid), }); + } else { + try { + const returnTo = await oidc.interactionResult(req, resp, { + login: { accountId: req.session.user.sub }, + }); + + req.session.destroy(() => { + res({ type: "redirect", to: returnTo }); + }); + } catch (e) { + console.error("Error while in interaction middleware", e); + + req.session.destroy(() => { + if (e instanceof OIDC_Errors.SessionNotFound) { + res({ type: "error", error: "session_lost" }); + } else { + res({ type: "error", error: "unknown" }); + } + }); + } } + } else { + res({ type: "continue" }); } + }); +}; - return; - } - - next(); -}); - -if (process.env.CLIENT_HOST) { - app.get(["/interaction*", "/login"], (req, res) => { - const url = new URL(req.originalUrl, process.env.CLIENT_HOST!); - res.redirect(url.toString()); +if (process.env.NODE_ENV === "development") { + // expose the internals of the interaction middleware for the vite dev server to access + app.post("/_dev/interaction/:uid", async (req, res) => { + const middleware = await interactionMiddleware(req, res); + + switch (middleware.type) { + case "redirect": + res.redirect(middleware.to); + break; + case "error": + switch (middleware.error) { + case "session_lost": + res.send("

session lost

Try login again

"); + break; + case "unknown": + res.send("

unknown error

"); + break; + } + break; + case "continue": + default: + res.end(); + break; + } }); } +app.use("/interaction/:uid", async (req, res, next) => { + const middleware = await interactionMiddleware(req, res); + + switch (middleware.type) { + case "redirect": + res.redirect(middleware.to); + break; + case "error": + switch (middleware.error) { + case "session_lost": + res.send("

session lost

Try login again

"); + break; + case "unknown": + res.send("

unknown error

"); + break; + } + break; + case "continue": + default: + next(); + break; + } +}); + if (process.env.SERVE_FRONTEND) { const indexFile = path.join(process.env.SERVE_FRONTEND, "index.html"); diff --git a/backend/src/lib/oidc.ts b/backend/src/lib/oidc.ts index b4c34e9500538986fd95b61e2ff3807e8b415699..2238de744b20a32738c40b15664f9fa4e2b88da9 100644 --- a/backend/src/lib/oidc.ts +++ b/backend/src/lib/oidc.ts @@ -4,6 +4,7 @@ import { PrismaAdapter } from "./adapter.js"; import { Issuer } from "openid-client"; import { IInstance, getInstanceMeta } from "./instance/instanceMeta.js"; import { IProfile, getUserMeta } from "./instance/userMeta.js"; +import { ShadowAPI, UserLoginError } from "./shadow.js"; /** * ⚠ DEVELOPMENT KEYS ⚠ @@ -113,6 +114,115 @@ export const oidc = new Provider(process.env.OIDC_ISSUER!, { }, }); +type RouteName = + | "authorization" + | "backchannel_authentication" + | "client_delete" + | "client_update" + | "client" + | "code_verification" + | "cors.device_authorization" + | "cors.discovery" + | "cors.introspection" + | "cors.jwks" + | "cors.pushed_authorization_request" + | "cors.revocation" + | "cors.token" + | "cors.userinfo" + | "device_authorization" + | "device_resume" + | "discovery" + | "end_session_confirm" + | "end_session_success" + | "end_session" + | "introspection" + | "jwks" + | "pushed_authorization_request" + | "registration" + | "resume" + | "revocation" + | "token" + | "userinfo"; + +/** + * Check if the requesting/requested user is banned via Shadow + * + * Requesting user: /api/oidc/auth + * Requested user: /api/oidc/token,/api/oidc/me + * + * Requesting user should be given an UI explaining why, with an option to logout + * + * Requested user should return an access_token invalid error + */ +oidc.use(async (ctx, next) => { + // check shadow privileges + + if (ctx.path.startsWith("/api/oidc/auth")) { + // force all auth to show consent page + ctx.query.prompt = "consent"; + } + + await next(); + + const route: RouteName | undefined = ctx.oidc?.route; + + const BLACKLISTED_ENDPOINTS: RouteName[] = [ + "authorization", + "backchannel_authentication", + "code_verification", + "token", + "userinfo", + "cors.token", + "cors.userinfo", + ]; + + if (!route) return; + + if (BLACKLISTED_ENDPOINTS.indexOf(route) === -1) { + // not an endpoint we care about + return; + } + + switch (route) { + case "token": + case "userinfo": + case "cors.token": + case "cors.userinfo": + if ("access_token" in ctx.body) { + // is an authorization response + + const token = await oidc.AccessToken.find(ctx.body.access_token); + if (!token) { + // no idea how we got here, but we can just let it be + return; + } + + const { accountId } = token; + + try { + await ShadowAPI.canUserLogin({ sub: accountId, ip: ctx.ip }); + } catch (e) { + await token.destroy(); // regardless of error, we destroy the access token + + if (e instanceof UserLoginError) { + // has details we can share + ctx.body = { + error: "invalid_grant", + error_description: "shadow: " + e.message, + }; + } else { + // internal shadow error + ctx.body = { + error: "invalid_grant", + error_description: "internal error", + }; + } + } + } + break; + } +}); + /** * Check if instance supports OIDC discovery and dynamic client registration * @param instance_hostname diff --git a/backend/src/lib/shadow.ts b/backend/src/lib/shadow.ts new file mode 100644 index 0000000000000000000000000000000000000000..226c10d0f3c7888d9da0a21ea9b302d54a899beb --- /dev/null +++ b/backend/src/lib/shadow.ts @@ -0,0 +1,96 @@ +/** + * Shadow moderation API utilities + */ + +// TODO: Redis subscriptions for ban notifications + +interface IShadowAPI { + /** + * Check if a user can login + * + * This should be expected to be called frequently + * Cache this and bust it with Redis subscription + * + * @throws UserLoginError on failure + * @param user + * @returns true + */ + canUserLogin(user: IShadowUser): Promise; +} + +class ShadowAPI_ implements IShadowAPI { + private async api( + endpoint: `/${string}`, + method = "GET", + body?: any + ): Promise<{ status: number; data: T }> { + let headers: { [k: string]: string } = { + Authorization: "Bearer " + process.env.SHADOW_TOKEN, + }; + let params: RequestInit = { + method, + }; + + if (typeof body !== "undefined") { + headers["Content-Type"] = "application/json"; + params.body = JSON.stringify(body); + } + + params.headers = headers; + + const req = await fetch( + process.env.SHADOW_HOST! + "/api/fediverse-auth/v1" + endpoint, + params + ); + + const res = await req.json(); + + return { + status: req.status, + data: res as any, + }; + } + + async canUserLogin(user: IShadowUser): Promise { + const { status, data } = await this.api<{ + can_login: boolean; + reason?: string; + }>("/login", "POST", { + sub: user.sub, + ip: user.ip, + }); + + if (status === 200 && data.can_login) { + return true; + } else { + throw new UserLoginError(data?.reason || "Unknown error"); + } + } +} + +export class UserLoginError extends Error { + constructor(reason: string) { + super(reason); + this.name = "UserLoginError"; + } +} + +/** + * The SHADOW_HOST environment variable isn't set + * + * Assume every login is permitted + */ +class ShadowAPI_Masked implements IShadowAPI { + async canUserLogin(user: IShadowUser): Promise { + return true; + } +} + +interface IShadowUser { + sub: string; + ip: string; +} + +export const ShadowAPI = process.env.SHADOW_HOST + ? new ShadowAPI_() + : new ShadowAPI_Masked(); diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index ba210ef651c9650d227e348a5b9ca9121814e818..ee41aa5e765c8756084fd5442355aa413792b207 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -2,6 +2,7 @@ import { NodeInfo } from "../types/nodeinfo.js"; import { IOIDC_Public_Client } from "../types/oidc.js"; import { getNodeInfo } from "./nodeinfo.js"; import { oidc } from "./oidc.js"; +import { type Request } from "express"; /** * Domain name regex @@ -71,3 +72,19 @@ export const getSafeURL = (unsafe_url: string): string | undefined => { return unsafe_url; }; + +export const getExpressIP = (req: Request): string => { + if (process.env.NODE_ENV === "production") { + let ip: string | undefined; + + if (typeof req.headers["x-forwarded-for"] === "string") { + ip = req.headers["x-forwarded-for"]; + } else { + ip = req.headers["x-forwarded-for"]?.[0]; + } + + return ip || req.socket.remoteAddress!; + } + + return req.socket.remoteAddress!; +}; diff --git a/backend/src/types/env.d.ts b/backend/src/types/env.d.ts index b2876225ca011366e9ebf3459a28a62409fff8e9..0d64b51554e7d458d3b01baae6f5e9a761773f9a 100644 --- a/backend/src/types/env.d.ts +++ b/backend/src/types/env.d.ts @@ -7,7 +7,6 @@ declare global { PORT: string; OIDC_ISSUER: string; - CLIENT_HOST?: string; LEMMY_HOST?: string; LEMMY_USER?: string; @@ -22,6 +21,11 @@ declare global { OIDC_COOKIE_KEYS_FILE?: string; OIDC_REGISTRATION_TOKEN?: string; USE_INSECURE?: string; + + SHADOW_HOST?: string; + SHADOW_TOKEN?: string; + + BYPASS_CODE_VERIFICATION?: string; } } } diff --git a/frontend/example.env b/frontend/example.env index fc87a6244dd651ed0603be9e927562197766c241..9e6caae61ec6ba660223b2ffed505ac907c5ce72 100644 --- a/frontend/example.env +++ b/frontend/example.env @@ -1,3 +1,3 @@ # Development Use # Specify the backend url -# VITE_APP_ROOT=http://localhost:3000 \ No newline at end of file +# DEV_BACKEND_HOST=http://localhost:3000 \ No newline at end of file diff --git a/frontend/src/Interaction/InteractionPage.tsx b/frontend/src/Interaction/InteractionPage.tsx index 3349d5275f067bc3fc0af5ab6b7eaae51460955f..0e5bbdab89599912c7497fdc9199d56966cfea5f 100644 --- a/frontend/src/Interaction/InteractionPage.tsx +++ b/frontend/src/Interaction/InteractionPage.tsx @@ -23,7 +23,10 @@ export const InteractionPage = () => { const [loading_approve, setLoading_Approve] = useState(false); const [loading_deny, setLoading_Deny] = useState(false); const [fatalError, setFatalError] = useState(); - const [error, setError] = useState(); + const [error, setError] = useState<{ + type: "shadow" | "error"; + message: string; + }>(); const [interaction, setInteraction] = useState(); const [user, setUser] = useState<{ sub: string }>(); @@ -85,13 +88,31 @@ export const InteractionPage = () => { const doApprove = async () => { setLoading_Approve(true); api< - { success: true; returnTo: string } | { success: false; error: string } + | { success: true; returnTo: string } + | { success: false; error: string; metadata?: any } >("/api/v1/interaction/" + interactionId + "/confirm", "POST") - .then((data) => { - if (data.status === 200 && data.data.success) { - window.open(data.data.returnTo, "_self"); + .then(({ status, data }): any => { + if (status === 200 && data.success) { + window.open(data.returnTo, "_self"); } else { - setError((data.data as any).error); + if (data.success) { + setError({ + type: "error", + message: "Unknown error", + }); + } else { + if (data.error === "shadow") { + setError({ + type: "shadow", + message: data.metadata?.message || "Unknown Error", + }); + } else { + setError({ + type: "error", + message: data.error, + }); + } + } } }) .finally(() => { @@ -102,7 +123,8 @@ export const InteractionPage = () => { const doDeny = async () => { setLoading_Deny(true); api< - { success: true; returnTo: string } | { success: false; error: string } + | { success: true; returnTo: string } + | { success: false; error: string; metadata?: any } >("/api/v1/interaction/" + interactionId + "/abort", "POST") .then((data) => { if (data.status === 200 && data.data.success) { @@ -132,7 +154,17 @@ export const InteractionPage = () => { Fediverse Auth - {error && {error}} + {error?.type === "error" && ( + {error.message} + )} + + {error?.type === "shadow" && ( + + Shadow Error +
+ {error.message} +
+ )} {client && } {user ? ( diff --git a/frontend/src/Logout/Logout.tsx b/frontend/src/Logout/Logout.tsx index ac14cc6e8db5f2beecf043ab9e672b3f13764388..25031a1f42bc720a12b9291984d8dca5717903f4 100644 --- a/frontend/src/Logout/Logout.tsx +++ b/frontend/src/Logout/Logout.tsx @@ -17,7 +17,7 @@ export const LogoutPage = () => { >(); useEffect(() => { - fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/whoami", { + fetch("/api/v1/whoami", { credentials: "include", }) .then((a) => a.json()) @@ -33,7 +33,7 @@ export const LogoutPage = () => { const doLogout = () => { setLoading(true); - fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/logout", { + fetch("/api/v1/logout", { method: "POST", credentials: "include", }) diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..fea1047f1aa79eedee74667a9e9e3df7fb6aee1f --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV: "development" | "production"; + DEV_BACKEND_HOST?: string; + } + } +} + +export {}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 861b04b35601de92787a1a0db6c9fa190975d220..4fd54843ca6be1ff9ba5c125bd25417b8b9df6fc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,99 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import { PluginOption, defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +/** + * Run middlewares during development + * @param API_BACKEND_HOST + * @returns + */ +function dev_middleware(API_BACKEND_HOST: string | undefined): PluginOption { + return { + name: "dev-middleware", + config() { + return { + server: {}, + preview: {}, + }; + }, + configureServer(server) { + if (typeof API_BACKEND_HOST === "undefined") { + throw new Error("DEV_BACKEND_HOST is not specified"); + } + + // the backend has interaction middleware to sync login sessions w/ oidc-provider + // this adds the middleware to vite + server.middlewares.use("/interaction", async (req, res, next) => { + // grab the interaction ID from the url (which has the format of /interaction/:uid) + const interactionId = (req.url || "").slice(1).split("/")[0]; + + // send request to backend using development endpoint + // (only exposed when backend is NODE_ENV===development) + const middle = await fetch( + API_BACKEND_HOST + "/_dev/interaction/" + interactionId, + { + method: "POST", + redirect: "manual", + headers: { + // we need to pass the cookies the client is using to keep sessions + cookie: req.headers.cookie || "", + }, + } + ); + + // if the middleware is redirecting we need to follow the redirect + if (middle.headers.get("Location")) { + let location = new URL( + middle.headers.get("Location")!, + "http://" + req.headers.host + ); + + if (location.host !== req.headers.host) { + // sometimes the backend will send a redirect including the backend's port, + // which will cause an error in development mode due to the backend not serving the client + location.host = req.headers.host!; + } + + // use a temporary redirect, like what the backend would send + res.statusCode = 302; + res.setHeader("Location", location.toString()); + res.end(); + return; + } + + // something errored in the backend + if (middle.status !== 200) { + console.log(await middle.text()); + res.write( + "Backend internal error, check console (development message)" + ); + res.end(); + return; + } + + // the middleware didn't need to do anything, resume normal rendering + next(); + }); + }, + }; +} // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], -}) +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + + if (env.DEV_BACKEND_HOST) { + return { + plugins: [react(), dev_middleware(env.DEV_BACKEND_HOST)], + server: { + proxy: { + "/api": env.DEV_BACKEND_HOST, + "/logout": env.DEV_BACKEND_HOST, + }, + }, + }; + } + + return { + plugins: [react()], + }; +});