From fea8f906d7df9a6a5c10a71f050fe20f559e3a0c Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Mon, 4 Apr 2022 17:50:40 -0400 Subject: [PATCH] (core) add a login method based on headers Summary: This fleshes out header-based authentication a little more to work with traefik-forward-auth. Test Plan: manually tested Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3348 --- app/server/lib/Authorizer.ts | 7 +-- app/server/lib/FlexServer.ts | 5 ++ app/server/lib/ForwardAuthLogin.ts | 75 ++++++++++++++++++++++++++++++ app/server/lib/GristServer.ts | 17 ++++++- app/server/lib/MinimalLogin.ts | 37 ++++----------- stubs/app/server/lib/logins.ts | 7 +-- 6 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 app/server/lib/ForwardAuthLogin.ts diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 6c735317..6d321e51 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -94,10 +94,11 @@ export function isSingleUserMode(): boolean { /** * Returns a profile if it can be deduced from the request. This requires a * header to specify the users' email address. The header to set comes from the - * environment variable GRIST_PROXY_AUTH_HEADER. + * environment variable GRIST_PROXY_AUTH_HEADER, or may be passed in. */ -export function getRequestProfile(req: Request|IncomingMessage): UserProfile|undefined { - const header = process.env.GRIST_PROXY_AUTH_HEADER; +export function getRequestProfile(req: Request|IncomingMessage, + header?: string): UserProfile|undefined { + header = header || process.env.GRIST_PROXY_AUTH_HEADER; let profile: UserProfile|undefined; if (header) { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 182962a6..9ec5f292 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -729,6 +729,11 @@ export class FlexServer implements GristServer { recordSignUpEvent: true, }}); + if (process.env.GRIST_SINGLE_ORG) { + // Merged org is not meaningful in this case. + return res.redirect(this.getHomeUrl(req)); + } + // Redirect to teams page if users has access to more than one org. Otherwise, redirect to // personal org. const domain = mreq.org; diff --git a/app/server/lib/ForwardAuthLogin.ts b/app/server/lib/ForwardAuthLogin.ts new file mode 100644 index 00000000..68dd3d9f --- /dev/null +++ b/app/server/lib/ForwardAuthLogin.ts @@ -0,0 +1,75 @@ +import { ApiError } from 'app/common/ApiError'; +import { getRequestProfile } from 'app/server/lib/Authorizer'; +import { expressWrap } from 'app/server/lib/expressWrap'; +import { GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer'; +import { optStringParam } from 'app/server/lib/requestUtils'; +import * as express from 'express'; +import trimEnd = require('lodash/trimEnd'); +import trimStart = require('lodash/trimStart'); + +/** + * Return a login system that can work in concert with middleware that + * does authentication and then passes identity in a header. An example + * of such middleware is traefik-forward-auth: + * + * https://github.com/thomseddon/traefik-forward-auth + * + * To make it function: + * - Set GRIST_FORWARD_AUTH_HEADER to a header that will contain + * authorized user emails, say "x-forwarded-user" + * - Make sure /auth/login is processed by forward auth middleware + * - Set GRIST_FORWARD_AUTH_LOGOUT_PATH to a path that will trigger + * a logout (for traefik-forward-auth by default that is /_oauth/logout). + * - Make sure that logout path is processed by forward auth middleware + * - If you want to allow anonymous access in some cases, make sure all + * other paths are free of the forward auth middleware - Grist will + * trigger it as needed. + * - Optionally, tell the middleware where to forward back to after logout. + * (For traefik-forward-auth, you'd set LOGOUT_REDIRECT to .../signed-out) + * + * Redirection logic currently assumes a single-site installation. + */ +export async function getForwardAuthLoginSystem(): Promise { + const header = process.env.GRIST_FORWARD_AUTH_HEADER; + const logoutPath = process.env.GRIST_FORWARD_AUTH_LOGOUT_PATH || ''; + if (!header) { return; } + return { + async getMiddleware(gristServer: GristServer) { + async function getLoginRedirectUrl(req: express.Request, url: URL) { + const target = new URL(trimEnd(gristServer.getHomeUrl(req), '/') + "/auth/login"); + // In lieu of sanatizing the next url, we include only the path + // component. This will only work for single-domain installations. + target.searchParams.append('next', url.pathname); + return target.href; + } + return { + getLoginRedirectUrl, + getSignUpRedirectUrl: getLoginRedirectUrl, + async getLogoutRedirectUrl(req: express.Request) { + return trimEnd(gristServer.getHomeUrl(req), '/') + '/' + + trimStart(logoutPath, '/'); + }, + async addEndpoints(app: express.Express) { + app.get('/auth/login', expressWrap(async (req, res) => { + const profile = getRequestProfile(req, header); + if (!profile) { + throw new ApiError('cannot find user', 401); + } + await setUserInSession(req, gristServer, profile); + const target = new URL(gristServer.getHomeUrl(req)); + const next = optStringParam(req.query.next); + if (next) { + target.pathname = next; + } + res.redirect(target.href); + })); + return "forward-auth"; + }, + }; + }, + async deleteUser() { + // If we could delete the user account in the external + // authentication system, this is our chance - but we can't. + }, + }; +} diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 9b14c67c..92f66cbe 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -1,5 +1,5 @@ import { GristLoadConfig } from 'app/common/gristUrls'; -import { FullUser } from 'app/common/UserAPI'; +import { FullUser, UserProfile } from 'app/common/UserAPI'; import { Document } from 'app/gen-server/entity/Document'; import { Organization } from 'app/gen-server/entity/Organization'; import { Workspace } from 'app/gen-server/entity/Workspace'; @@ -12,6 +12,7 @@ import { IDocStorageManager } from 'app/server/lib/IDocStorageManager'; import { INotifier } from 'app/server/lib/INotifier'; import { IPermitStore } from 'app/server/lib/Permit'; import { ISendAppPageOptions } from 'app/server/lib/sendAppPage'; +import { fromCallback } from 'app/server/lib/serverUtils'; import { Sessions } from 'app/server/lib/Sessions'; import * as express from 'express'; @@ -59,6 +60,20 @@ export interface GristLoginMiddleware { addEndpoints(app: express.Express): Promise; } +/** + * Set the user in the current session. + */ +export async function setUserInSession(req: express.Request, gristServer: GristServer, profile: UserProfile) { + const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req); + // Make sure session is up to date before operating on it. + // Behavior on a completely fresh session is a little awkward currently. + const reqSession = (req as any).session; + if (reqSession?.save) { + await fromCallback(cb => reqSession.save(cb)); + } + await scopedSession.updateUserProfile(req, profile); +} + export interface RequestWithGrist extends express.Request { gristServer?: GristServer; } diff --git a/app/server/lib/MinimalLogin.ts b/app/server/lib/MinimalLogin.ts index f289fbda..e792dd81 100644 --- a/app/server/lib/MinimalLogin.ts +++ b/app/server/lib/MinimalLogin.ts @@ -1,7 +1,6 @@ -import {UserProfile} from 'app/common/UserAPI'; -import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer'; -import {fromCallback} from 'app/server/lib/serverUtils'; -import {Request} from 'express'; +import { UserProfile } from 'app/common/UserAPI'; +import { GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer'; +import { Request } from 'express'; /** * Return a login system that supports a single hard-coded user. @@ -11,18 +10,16 @@ export async function getMinimalLoginSystem(): Promise { // no nuance here. return { async getMiddleware(gristServer: GristServer) { + async function getLoginRedirectUrl(req: Request, url: URL) { + await setUserInSession(req, gristServer, getDefaultProfile()); + return url.href; + } return { - async getLoginRedirectUrl(req: Request, url: URL) { - await setSingleUser(req, gristServer); - return url.href; - }, + getLoginRedirectUrl, + getSignUpRedirectUrl: getLoginRedirectUrl, async getLogoutRedirectUrl(req: Request, url: URL) { return url.href; }, - async getSignUpRedirectUrl(req: Request, url: URL) { - await setSingleUser(req, gristServer); - return url.href; - }, async addEndpoints() { // If working without a login system, make sure default user exists. const dbManager = gristServer.getHomeDBManager(); @@ -43,22 +40,6 @@ export async function getMinimalLoginSystem(): Promise { }; } -/** - * Set the user in the current session to the single hard-coded user. - */ -async function setSingleUser(req: Request, gristServer: GristServer) { - const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req); - // Make sure session is up to date before operating on it. - // Behavior on a completely fresh session is a little awkward currently. - const reqSession = (req as any).session; - if (reqSession?.save) { - await fromCallback(cb => reqSession.save(cb)); - } - await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, { - profile: getDefaultProfile() - })); -} - function getDefaultProfile(): UserProfile { return { email: process.env.GRIST_DEFAULT_EMAIL || 'you@example.com', diff --git a/stubs/app/server/lib/logins.ts b/stubs/app/server/lib/logins.ts index bceeac2b..745ac7ba 100644 --- a/stubs/app/server/lib/logins.ts +++ b/stubs/app/server/lib/logins.ts @@ -1,9 +1,10 @@ +import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin'; import { GristLoginSystem } from 'app/server/lib/GristServer'; import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin'; import { getSamlLoginSystem } from 'app/server/lib/SamlConfig'; export async function getLoginSystem(): Promise { - const saml = await getSamlLoginSystem(); - if (saml) { return saml; } - return getMinimalLoginSystem(); + return await getSamlLoginSystem() || + await getForwardAuthLoginSystem() || + await getMinimalLoginSystem(); }