(core) Revamp ForwardAuthLogin and unify with GRIST_PROXY_AUTH_HEADER

Summary:
By default, only respect GRIST_FORWARD_AUTH_HEADER on login endpoints; sessions are used elsewhere.

With GRIST_IGNORE_SESSION, do not use sessions, and respect GRIST_FORWARD_AUTH_HEADER on all endpoints.

GRIST_PROXY_AUTH_HEADER is now a synonym to GRIST_FORWARD_AUTH_HEADER.

Test Plan: Fixed tests. Tested first approach (no GRIST_IGNORE_SESSION) with grist-omnibus manually. Tested the second approach (with GRIST_IGNORE_SESSION) with a Apache-based setup enforcing http basic auth on all endpoints.

Reviewers: paulfitz, georgegevoian

Reviewed By: paulfitz, georgegevoian

Differential Revision: https://phab.getgrist.com/D4104
This commit is contained in:
Dmitry S
2023-11-07 15:04:23 -05:00
parent b7e9d2705e
commit 3210eee24f
7 changed files with 153 additions and 94 deletions

View File

@@ -96,45 +96,39 @@ export function isSingleUserMode(): boolean {
return process.env.GRIST_SINGLE_USER === '1';
}
/**
* 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, or may be passed in.
* header to specify the users' email address.
* A result of null means that the user should be considered known to be anonymous.
* A result of undefined means we should go on to consider other authentication
* methods (such as cookies).
*/
export function getRequestProfile(req: Request|IncomingMessage,
header?: string): UserProfile|null|undefined {
header = header || process.env.GRIST_PROXY_AUTH_HEADER;
header: string): UserProfile|null|undefined {
let profile: UserProfile|null|undefined;
if (header) {
// Careful reading headers. If we have an IncomingMessage, there is no
// get() function, and header names are lowercased.
const headerContent = ('get' in req) ? req.get(header) : req.headers[header.toLowerCase()];
if (headerContent) {
const userEmail = headerContent.toString();
const [userName] = userEmail.split("@", 1);
if (userEmail && userName) {
profile = {
"email": userEmail,
"name": userName
};
}
}
// If no profile at this point, and header was present,
// treat as anonymous user, represented by null value.
// Don't go on to look at session.
if (!profile && headerContent !== undefined) {
profile = null;
// Careful reading headers. If we have an IncomingMessage, there is no
// get() function, and header names are lowercased.
const headerContent = ('get' in req) ? req.get(header) : req.headers[header.toLowerCase()];
if (headerContent) {
const userEmail = headerContent.toString();
const [userName] = userEmail.split("@", 1);
if (userEmail && userName) {
profile = {
"email": userEmail,
"name": userName
};
}
}
// If no profile at this point, and header was present,
// treat as anonymous user, represented by null value.
// Don't go on to look at session.
if (!profile && headerContent !== undefined) {
profile = null;
}
return profile;
}
/**
* Returns the express request object with user information added, if it can be
* found based on passed in headers or the session. Specifically, sets:
@@ -144,13 +138,15 @@ export function getRequestProfile(req: Request|IncomingMessage,
* as would typically be the case, credentials were not presented)
* - req.users: set for org-and-session-based logins, with list of profiles in session
*/
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
options: {
gristServer: GristServer,
skipSession?: boolean,
getProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>,
},
req: Request, res: Response, next: NextFunction) {
export async function addRequestUser(
dbManager: HomeDBManager, permitStore: IPermitStore,
options: {
gristServer: GristServer,
skipSession?: boolean,
overrideProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>,
},
req: Request, res: Response, next: NextFunction
) {
const mreq = req as RequestWithLogin;
let profile: UserProfile|undefined;
@@ -236,21 +232,20 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
// If this is the case, we won't use session information.
let skipSession: boolean = options.skipSession || authDone;
if (!authDone && !mreq.userId) {
let candidate = await options.getProfile?.(mreq);
if (candidate === undefined) {
candidate = getRequestProfile(mreq);
}
if (candidate !== undefined) {
const candidateProfile = await options.overrideProfile?.(mreq);
if (candidateProfile !== undefined) {
// Either a valid or a null profile tells us that another login system determined the user,
// and that we should skip sessions.
skipSession = true;
}
if (candidate) {
profile = candidate;
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
if (user) {
mreq.user = user;
mreq.users = [profile];
mreq.userId = user.id;
mreq.userIsAuthorized = true;
if (candidateProfile) {
profile = candidateProfile;
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
if (user) {
mreq.user = user;
mreq.users = [profile];
mreq.userId = user.id;
mreq.userIsAuthorized = true;
}
}
}
}
@@ -698,7 +693,7 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
...(Origin ? { Origin } : undefined),
};
const extraHeader = process.env.GRIST_PROXY_AUTH_HEADER;
const extraHeader = process.env.GRIST_FORWARD_AUTH_HEADER;
const extraHeaderValue = extraHeader && req.get(extraHeader);
if (extraHeader && extraHeaderValue) {
result[extraHeader] = extraHeaderValue;

View File

@@ -42,7 +42,6 @@ import {parseFirstUrlPart} from 'app/common/gristUrls';
import {firstDefined, safeJsonParse} from 'app/common/gutil';
import {UserProfile} from 'app/common/LoginSessionAPI';
import * as version from 'app/common/version';
import {getRequestProfile} from 'app/server/lib/Authorizer';
import {ScopedSession} from "app/server/lib/BrowserSession";
import {Client, ClientMethod} from "app/server/lib/Client";
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
@@ -198,8 +197,7 @@ export class Comm extends EventEmitter {
*/
private async _getSessionProfile(scopedSession: ScopedSession, req: http.IncomingMessage): Promise<UserProfile|null> {
return await firstDefined(
async () => this._options.loginMiddleware?.getProfile?.(req),
async () => getRequestProfile(req),
async () => this._options.loginMiddleware?.overrideProfile?.(req),
async () => scopedSession.getSessionProfile(),
) || null;
}

View File

@@ -671,7 +671,7 @@ export class FlexServer implements GristServer {
this._userIdMiddleware = expressWrap(addRequestUser.bind(
null, this._dbManager, this._internalPermitStore,
{
getProfile: this._loginMiddleware.getProfile?.bind(this._loginMiddleware),
overrideProfile: this._loginMiddleware.overrideProfile?.bind(this._loginMiddleware),
// Set this to false to stop Grist using a cookie for authentication purposes.
skipSession,
gristServer: this,

View File

@@ -1,43 +1,78 @@
import { ApiError } from 'app/common/ApiError';
import { UserProfile } from 'app/common/LoginSessionAPI';
import { appSettings } from 'app/server/lib/AppSettings';
import { getRequestProfile } from 'app/server/lib/Authorizer';
import { expressWrap } from 'app/server/lib/expressWrap';
import { GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer';
import { GristLoginMiddleware, GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
import { optStringParam } from 'app/server/lib/requestUtils';
import * as express from 'express';
import { IncomingMessage } from 'http';
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:
* does authentication and then passes identity in a header.
* There are two modes of operation, distinguished by whether GRIST_IGNORE_SESSION is set.
*
* https://github.com/thomseddon/traefik-forward-auth
* 1. With sessions, and forward-auth on login endpoints.
*
* 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)
* For example, using traefik reverse proxy with traefik-forward-auth middleware:
*
* https://github.com/thomseddon/traefik-forward-auth
*
* Grist environment:
* - GRIST_FORWARD_AUTH_HEADER: set to a header that will contain
* authorized user emails, say "x-forwarded-user"
* - GRIST_FORWARD_AUTH_LOGOUT_PATH: set to a path that will trigger
* a logout (for traefik-forward-auth by default that is /_oauth/logout).
* - GRIST_FORWARD_AUTH_LOGIN_PATH: optionally set to override the default (/auth/login).
* - GRIST_IGNORE_SESSION: do NOT set, or set to a falsy value.
*
* Reverse proxy:
* - Make sure your reverse proxy applies the forward auth middleware to
* GRIST_FORWARD_AUTH_LOGIN_PATH and GRIST_FORWARD_AUTH_LOGOUT_PATH.
* - 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 by redirecting to /auth/login.
* - Grist only uses the configured header at login/logout. Once the user is logged in, Grist
* will use the session info to identify the user, until logout.
* - Optionally, tell the middleware where to forward back to after logout.
* (For traefik-forward-auth, you'd run it with LOGOUT_REDIRECT set to .../signed-out)
*
* 2. With no sessions, and forward-auth on all endpoints.
*
* For example, using HTTP Basic Auth and server configuration that sets a header to the
* logged-in user (e.g. to REMOTE_USER with Apache).
*
* Grist environment:
* - GRIST_IGNORE_SESSION: set to true. Grist sessions will not be used.
* - GRIST_FORWARD_AUTH_HEADER: set to to a header that will contain authorized user emails, say
* "x-remote-user".
*
* Reverse proxy:
* - Make sure your reverse proxy sets the header you specified for all requests that may need
* login information. It is imperative that this header cannot be spoofed by the user, since
* Grist will trust whatever is in it.
*
* GRIST_PROXY_AUTH_HEADER is deprecated in favor of GRIST_FORWARD_AUTH_HEADER. It is currently
* interpreted as a synonym, with a warning, but support for it may be dropped.
*
* Redirection logic currently assumes a single-site installation.
*/
export async function getForwardAuthLoginSystem(): Promise<GristLoginSystem|undefined> {
const section = appSettings.section('login').section('system').section('forwardAuth');
const header = section.flag('header').readString({
envVar: 'GRIST_FORWARD_AUTH_HEADER',
const headerSetting = section.flag('header');
const header = headerSetting.readString({
envVar: ['GRIST_FORWARD_AUTH_HEADER', 'GRIST_PROXY_AUTH_HEADER']
});
if (!header) { return; }
if (!header) {
return;
}
if (headerSetting.describe().foundInEnvVar === 'GRIST_PROXY_AUTH_HEADER') {
log.warn("GRIST_PROXY_AUTH_HEADER is deprecated; interpreted as a synonym of GRIST_FORWARD_AUTH_HEADER");
}
section.flag('active').set(true);
const logoutPath = section.flag('logoutPath').readString({
envVar: 'GRIST_FORWARD_AUTH_LOGOUT_PATH'
@@ -45,18 +80,23 @@ export async function getForwardAuthLoginSystem(): Promise<GristLoginSystem|unde
const loginPath = section.flag('loginPath').requireString({
envVar: 'GRIST_FORWARD_AUTH_LOGIN_PATH',
defaultValue: '/auth/login',
}) || '';
});
const skipSession = appSettings.section('login').flag('skipSession').readBool({
envVar: 'GRIST_IGNORE_SESSION',
});
return {
async getMiddleware(gristServer: GristServer) {
async function getLoginRedirectUrl(req: express.Request, url: URL) {
const target = new URL(trimEnd(gristServer.getHomeUrl(req), '/') +
'/' + trimStart(loginPath, '/'));
// In lieu of sanatizing the next url, we include only the path
// In lieu of sanitizing 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 {
const middleware: GristLoginMiddleware = {
getLoginRedirectUrl,
getSignUpRedirectUrl: getLoginRedirectUrl,
async getLogoutRedirectUrl(req: express.Request) {
@@ -64,7 +104,7 @@ export async function getForwardAuthLoginSystem(): Promise<GristLoginSystem|unde
trimStart(logoutPath, '/');
},
async addEndpoints(app: express.Express) {
app.get('/auth/login', expressWrap(async (req, res) => {
app.get(loginPath, expressWrap(async (req, res) => {
const profile = getRequestProfile(req, header);
if (!profile) {
throw new ApiError('cannot find user', 401);
@@ -77,12 +117,14 @@ export async function getForwardAuthLoginSystem(): Promise<GristLoginSystem|unde
}
res.redirect(target.href);
}));
return "forward-auth";
},
async getProfile(req: express.Request|IncomingMessage): Promise<UserProfile|null|undefined> {
return getRequestProfile(req, header);
return skipSession ? "forward-auth-skip-session" : "forward-auth";
},
};
if (skipSession) {
// With GRIST_IGNORE_SESSION, respect the header for all requests.
middleware.overrideProfile = async (req) => getRequestProfile(req, header);
}
return middleware;
},
async deleteUser() {
// If we could delete the user account in the external

View File

@@ -79,10 +79,11 @@ export interface GristLoginMiddleware {
getWildcardMiddleware?(): express.RequestHandler[];
// Returns arbitrary string for log.
addEndpoints(app: express.Express): Promise<string>;
// Optionally, extract profile from request. Result can be a profile,
// or null if anonymous (and other methods of determining profile such
// as a cookie should not be used), or undefined to use other methods.
getProfile?(req: express.Request|IncomingMessage): Promise<UserProfile|null|undefined>;
// Normally, the profile is obtained from the user's session object, which is set at login, and
// is identified by a session cookie. When given, overrideProfile() will be called first to
// extract the profile from each request. Result can be a profile, or null if anonymous
// (sessions will then not be used), or undefined to fall back to using session info.
overrideProfile?(req: express.Request|IncomingMessage): Promise<UserProfile|null|undefined>;
// Called on first visit to an app page after a signup, for reporting or telemetry purposes.
onFirstVisit?(req: express.Request): void;
}