gristlabs_grist-core/app/server/lib/Authorizer.ts
Paul Fitzpatrick de703343d0 (core) disentangle some server tests, release to core, add GRIST_PROXY_AUTH_HEADER test
Summary:
This shuffles some server tests to make them available in grist-core,
and adds a test for the `GRIST_PROXY_AUTH_HEADER` feature added in
https://github.com/gristlabs/grist-core/pull/165

It includes a fix for a header normalization issue for websocket connections.

Test Plan: added test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3326
2022-03-24 15:11:32 -04:00

600 lines
24 KiB
TypeScript

import {ApiError} from 'app/common/ApiError';
import {OpenDocMode} from 'app/common/DocListAPI';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
import {Document} from 'app/gen-server/entity/Document';
import {User} from 'app/gen-server/entity/User';
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
import {makeId} from 'app/server/lib/idUtils';
import * as log from 'app/server/lib/log';
import {IPermitStore, Permit} from 'app/server/lib/Permit';
import {allowHost, optStringParam} from 'app/server/lib/requestUtils';
import * as cookie from 'cookie';
import {NextFunction, Request, RequestHandler, Response} from 'express';
import {IncomingMessage} from 'http';
import * as onHeaders from 'on-headers';
export interface RequestWithLogin extends Request {
sessionID: string;
session: SessionObj;
org?: string;
isCustomHost?: boolean; // when set, the request's domain is a recognized custom host linked
// with the specified org.
users?: UserProfile[];
userId?: number;
user?: User;
userIsAuthorized?: boolean; // If userId is for "anonymous", this will be false.
docAuth?: DocAuthResult; // For doc requests, the docId and the user's access level.
specialPermit?: Permit;
altSessionId?: string; // a session id for use in trigger formulas and granular access rules
}
/**
* Extract the user id from a request, assuming we've added it via appropriate middleware.
* Throws ApiError with code 401 (unauthorized) if the user id is missing.
*/
export function getUserId(req: Request): number {
const userId = (req as RequestWithLogin).userId;
if (!userId) {
throw new ApiError("user not known", 401);
}
return userId;
}
/**
* Extract the user object from a request, assuming we've added it via appropriate middleware.
* Throws ApiError with code 401 (unauthorized) if the user is missing.
*/
export function getUser(req: Request): User {
const user = (req as RequestWithLogin).user;
if (!user) {
throw new ApiError("user not known", 401);
}
return user;
}
/**
* Extract the user profiles from a request, assuming we've added them via appropriate middleware.
* Throws ApiError with code 401 (unauthorized) if the profiles are missing.
*/
export function getUserProfiles(req: Request): UserProfile[] {
const users = (req as RequestWithLogin).users;
if (!users) {
throw new ApiError("user profile not found", 401);
}
return users;
}
// Extract the user id from a request, requiring it to be authorized (not an anonymous session).
export function getAuthorizedUserId(req: Request) {
const userId = getUserId(req);
if (isAnonymousUser(req)) {
throw new ApiError("user not authorized", 401);
}
return userId;
}
export function isAnonymousUser(req: Request) {
return !(req as RequestWithLogin).userIsAuthorized;
}
// True if Grist is configured for a single user without specific authorization
// (classic standalone/electron mode).
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.
*/
export function getRequestProfile(req: Request|IncomingMessage): UserProfile|undefined {
const header = process.env.GRIST_PROXY_AUTH_HEADER;
let profile: UserProfile|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
};
}
}
}
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:
* - req.userId: the id of the user in the database users table
* - req.userIsAuthorized: set if user has presented credentials that were accepted
* (the anonymous user has a userId but does not have userIsAuthorized set if,
* 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,
req: Request, res: Response, next: NextFunction) {
const mreq = req as RequestWithLogin;
let profile: UserProfile|undefined;
// First, check for an apiKey
if (mreq.headers && mreq.headers.authorization) {
// header needs to be of form "Bearer XXXXXXXXX" to apply
const parts = String(mreq.headers.authorization).split(' ');
if (parts[0] === "Bearer") {
const user = parts[1] ? await dbManager.getUserByKey(parts[1]) : undefined;
if (!user) {
return res.status(401).send('Bad request: invalid API key');
}
if (user.id === dbManager.getAnonymousUserId()) {
// We forbid the anonymous user to present an api key. That saves us
// having to think through the consequences of authorized access to the
// anonymous user's profile via the api (e.g. how should the api key be managed).
return res.status(401).send('Credentials cannot be presented for the anonymous user account via API key');
}
mreq.user = user;
mreq.userId = user.id;
mreq.userIsAuthorized = true;
}
}
// Special permission header for internal housekeeping tasks
if (mreq.headers && mreq.headers.permit) {
const permitKey = String(mreq.headers.permit);
try {
const permit = await permitStore.getPermit(permitKey);
if (!permit) { return res.status(401).send('Bad request: unknown permit'); }
mreq.user = dbManager.getAnonymousUser();
mreq.userId = mreq.user.id;
mreq.specialPermit = permit;
} catch (err) {
log.error(`problem reading permit: ${err}`);
return res.status(401).send('Bad request: permit could not be read');
}
}
// If we haven't already been authenticated, and this is not a GET/HEAD/OPTIONS, then
// require that the X-Requested-With header field be set to XMLHttpRequest.
// This is trivial for legitimate web clients to do, and an obstacle to
// nefarious ones.
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
// https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) {
return res.status(401).send('Bad request (missing header)');
}
// A bit of extra info we'll add to the "Auth" log message when this request passes the check
// for custom-host-specific sessionID.
let customHostSession = '';
// If we haven't selected a user by other means, and have profiles available in the
// session, then select a user based on those profiles.
const session = mreq.session;
if (session && !session.altSessionId) {
// Create a default alternative session id for use in documents.
session.altSessionId = makeId();
forceSessionChange(session);
}
mreq.altSessionId = session?.altSessionId;
if (!mreq.userId && session && session.users && session.users.length > 0 &&
mreq.org !== undefined) {
// Prevent using custom-domain sessionID to authorize to a different domain, since
// custom-domain owner could hijack such sessions.
const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
if (allowedOrg) {
if (allowHost(req, allowedOrg.host)) {
customHostSession = ` custom-host-match ${allowedOrg.host}`;
} else {
// We need an exception for internal forwarding from home server to doc-workers. These use
// internal hostnames, so we can't expect a custom domain. These requests do include an
// Organization header, which we'll use to grant the exception, but security issues remain.
// TODO Issue 1: an attacker can use a custom-domain request to get an API key, which is an
// open door to all orgs accessible by this user.
// TODO Issue 2: Organization header is easy for an attacker (who has stolen a session
// cookie) to include too; it does nothing to prove that the request is internal.
const org = req.header('organization');
if (org && org === allowedOrg.org) {
customHostSession = ` custom-host-fwd ${org}`;
} else {
// Log error and fail.
log.warn("Auth[%s]: sessionID for host %s org %s; wrong for host %s org %s", mreq.method,
allowedOrg.host, allowedOrg.org, mreq.get('host'), mreq.org);
return res.status(403).send('Bad request: invalid session ID');
}
}
}
mreq.users = getSessionProfiles(session);
// If we haven't set a maxAge yet, set it now.
if (session && session.cookie && !session.cookie.maxAge) {
session.cookie.maxAge = COOKIE_MAX_AGE;
forceSessionChange(session);
}
// See if we have a profile linked with the active organization already.
// TODO: implement userSelector for rest API, to allow "sticky" user selection on pages.
let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, optStringParam(mreq.query.user) || '');
if (!sessionUser) {
// No profile linked yet, so let's elect one.
// Choose a profile that is no worse than the others available.
const option = await dbManager.getBestUserForOrg(mreq.users, mreq.org);
if (option) {
// Modify request session object to link the current org with our choice of
// profile. Express-session will save this change.
sessionUser = linkOrgWithEmail(session, option.email, mreq.org);
// In this special case of initially linking a profile, we need to look up the user's info.
mreq.user = await dbManager.getUserByLogin(option.email);
mreq.userId = option.id;
mreq.userIsAuthorized = true;
} else {
// No profile has access to this org. We could choose to
// link no profile, in which case user will end up
// immediately presented with a sign-in page, or choose to
// link an arbitrary profile (say, the first one the user
// logged in as), in which case user will end up with a
// friendlier page explaining the situation and offering to
// add an account to resolve it. We go ahead and pick an
// arbitrary profile.
sessionUser = session.users[0];
if (!session.orgToUser) { throw new Error("Session misconfigured"); }
// Express-session will save this change.
session.orgToUser[mreq.org] = 0;
}
}
profile = sessionUser && sessionUser.profile || undefined;
// If we haven't computed a userId yet, check for one using an email address in the profile.
// A user record will be created automatically for emails we've never seen before.
if (profile && !mreq.userId) {
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
if (user) {
mreq.user = user;
mreq.userId = user.id;
mreq.userIsAuthorized = true;
}
}
}
if (!mreq.userId) {
profile = getRequestProfile(mreq);
if (profile) {
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
if(user) {
mreq.user = user;
mreq.users = [profile];
mreq.userId = user.id;
mreq.userIsAuthorized = true;
}
}
}
// If no userId has been found yet, fall back on anonymous.
if (!mreq.userId) {
const anon = dbManager.getAnonymousUser();
mreq.user = anon;
mreq.userId = anon.id;
mreq.userIsAuthorized = false;
mreq.users = [dbManager.makeFullUser(anon)];
}
log.debug("Auth[%s]: id %s email %s host %s path %s org %s%s", mreq.method,
mreq.userId, mreq.user?.loginEmail, mreq.get('host'), mreq.path, mreq.org,
customHostSession);
return next();
}
/**
* Returns a handler that redirects the user to a login or signup page.
*/
export function redirectToLoginUnconditionally(
getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>
) {
return async (req: Request, resp: Response, next: NextFunction) => {
const mreq = req as RequestWithLogin;
// Tell express-session to set our cookie: session handling post-login relies on it.
forceSessionChange(mreq.session);
// Redirect to sign up if it doesn't look like the user has ever logged in (on
// this browser) After logging in, `users` will be set in the session. Even after
// logging out again, `users` will still be set.
const signUp: boolean = (mreq.session.users === undefined);
log.debug(`Authorizer: redirecting to ${signUp ? 'sign up' : 'log in'}`);
const redirectUrl = new URL(req.protocol + '://' + req.get('host') + req.originalUrl);
if (signUp) {
return resp.redirect(await getSignUpRedirectUrl(req, redirectUrl));
} else {
return resp.redirect(await getLoginRedirectUrl(req, redirectUrl));
}
};
}
/**
* Middleware to redirects user to a login page when the user is not
* logged in. If allowExceptions is set, then we make an exception
* for a team site allowing anonymous access, or a personal doc
* allowing anonymous access, or the merged org.
*/
export function redirectToLogin(
allowExceptions: boolean,
getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,
dbManager: HomeDBManager
): RequestHandler {
const redirectUnconditionally = redirectToLoginUnconditionally(getLoginRedirectUrl,
getSignUpRedirectUrl);
return async (req: Request, resp: Response, next: NextFunction) => {
const mreq = req as RequestWithLogin;
// This will ensure that express-session will set our cookie if it hasn't already -
// we'll need it if we redirect.
forceSessionChange(mreq.session);
if (mreq.userIsAuthorized) { return next(); }
try {
// Otherwise it's an anonymous user. Proceed normally only if the org allows anon access,
// or if the org is not set (FlexServer._redirectToOrg will deal with that case).
if (mreq.userId && allowExceptions) {
// Anonymous user has qualified access to merged org.
// If no org is set, leave it to other middleware. One common case where the
// org is not set is when it is embedded in the url, and the user visits '/'.
// If we immediately require a login, it could fail if no cookie exists yet.
// Also, '/o/docs' allows anonymous access.
if (!mreq.org || dbManager.isMergedOrg(mreq.org)) { return next(); }
const result = await dbManager.getOrg({userId: mreq.userId}, mreq.org);
if (result.status === 200) { return next(); }
}
// In all other cases (including unknown org), redirect user to login or sign up.
return redirectUnconditionally(req, resp, next);
} catch (err) {
log.info("Authorizer failed to redirect", err.message);
return resp.status(401).send(err.message);
}
};
}
/**
* Sets mreq.docAuth if not yet set, and returns it.
*/
export async function getOrSetDocAuth(
mreq: RequestWithLogin, dbManager: HomeDBManager, urlId: string
): Promise<DocAuthResult> {
if (!mreq.docAuth) {
let effectiveUserId = getUserId(mreq);
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {
effectiveUserId = dbManager.getPreviewerUserId();
}
mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org});
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() &&
mreq.specialPermit.docId === mreq.docAuth.docId) {
mreq.docAuth = {...mreq.docAuth, access: 'owners'};
}
}
return mreq.docAuth;
}
export interface ResourceSummary {
kind: 'doc';
id: string|number;
}
/**
*
* Handle authorization for a single document accessed by a given user.
*
*/
export interface Authorizer {
// get the id of user, or null if no authorization in place.
getUserId(): number|null;
// get user profile if available.
getUser(): FullUser|null;
// get the id of the document.
getDocId(): string;
// get any link parameters in place when accessing the resource.
getLinkParameters(): Record<string, string>;
// Fetch the doc metadata from HomeDBManager.
getDoc(): Promise<Document>;
// Check access, throw error if the requested level of access isn't available.
assertAccess(role: 'viewers'|'editors'|'owners'): Promise<void>;
// Get the lasted access information calculated for the doc. This is useful
// for logging - but access control itself should use assertAccess() to
// ensure the data is fresh.
getCachedAuth(): DocAuthResult;
}
/**
*
* Handle authorization for a single document and user.
*
*/
export class DocAuthorizer implements Authorizer {
constructor(
private _dbManager: HomeDBManager,
private _key: DocAuthKey,
public readonly openMode: OpenDocMode,
public readonly linkParameters: Record<string, string>,
private _docAuth?: DocAuthResult,
private _profile?: UserProfile
) {
}
public getUserId(): number {
return this._key.userId;
}
public getUser(): FullUser|null {
return this._profile ? {id: this.getUserId(), ...this._profile} : null;
}
public getDocId(): string {
// We've been careful to require urlId === docId, see DocManager.
return this._key.urlId;
}
public getLinkParameters(): Record<string, string> {
return this.linkParameters;
}
public async getDoc(): Promise<Document> {
return this._dbManager.getDoc(this._key);
}
public async assertAccess(role: 'viewers'|'editors'|'owners'): Promise<void> {
const docAuth = await this._dbManager.getDocAuthCached(this._key);
this._docAuth = docAuth;
assertAccess(role, docAuth, {openMode: this.openMode});
}
public getCachedAuth(): DocAuthResult {
if (!this._docAuth) { throw Error('no cached authentication'); }
return this._docAuth;
}
}
export class DummyAuthorizer implements Authorizer {
constructor(public role: Role|null, public docId: string) {}
public getUserId() { return null; }
public getUser() { return null; }
public getDocId() { return this.docId; }
public getLinkParameters() { return {}; }
public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); }
public async assertAccess() { /* noop */ }
public getCachedAuth(): DocAuthResult {
return {
access: this.role,
docId: this.docId,
removed: false,
};
}
}
export function assertAccess(
role: 'viewers'|'editors'|'owners', docAuth: DocAuthResult, options: {
openMode?: OpenDocMode,
allowRemoved?: boolean,
} = {}) {
const openMode = options.openMode || 'default';
const details = {status: 403, accessMode: openMode};
if (docAuth.error) {
if ([400, 401, 403].includes(docAuth.error.status)) {
// For these error codes, we know our access level - forbidden. Make errors more uniform.
throw new ErrorWithCode("AUTH_NO_VIEW", "No view access", details);
}
throw docAuth.error;
}
if (docAuth.removed && !options.allowRemoved) {
throw new ErrorWithCode("AUTH_NO_VIEW", "Document is deleted", {status: 404});
}
// If docAuth has no error, the doc is accessible, but we should still check the level (in case
// it's possible to access the doc with a level less than "viewer").
if (!canView(docAuth.access)) {
throw new ErrorWithCode("AUTH_NO_VIEW", "No view access", details);
}
if (role === 'editors') {
// If opening in a fork or view mode, treat user as viewer and deny write access.
const access = (openMode === 'fork' || openMode === 'view') ?
getWeakestRole('viewers', docAuth.access) : docAuth.access;
if (!canEdit(access)) {
throw new ErrorWithCode("AUTH_NO_EDIT", "No write access", details);
}
}
}
/**
* Pull out headers to pass along to a proxied service. Focused primarily on
* authentication.
*/
export function getTransitiveHeaders(req: Request): {[key: string]: string} {
const Authorization = req.get('Authorization');
const Cookie = req.get('Cookie');
const PermitHeader = req.get('Permit');
const Organization = (req as RequestWithOrg).org;
const XRequestedWith = req.get('X-Requested-With');
const Origin = req.get('Origin'); // Pass along the original Origin since it may
// play a role in granular access control.
const result: Record<string, string> = {
...(Authorization ? { Authorization } : undefined),
...(Cookie ? { Cookie } : undefined),
...(Organization ? { Organization } : undefined),
...(PermitHeader ? { Permit: PermitHeader } : undefined),
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
...(Origin ? { Origin } : undefined),
};
const extraHeader = process.env.GRIST_PROXY_AUTH_HEADER;
const extraHeaderValue = extraHeader && req.get(extraHeader);
if (extraHeader && extraHeaderValue) {
result[extraHeader] = extraHeaderValue;
}
return result;
}
export const signInStatusCookieName = sessionCookieName + '_status';
// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.
// Its value is SignInStatus ("S", "M" or unset). This middleware keeps this cookie in sync with
// the session state.
//
// Note that this extra cookie isn't strictly necessary today: since it has similar settings to
// the session cookie, subdomains can infer status from that one. It is here in anticipation that
// we make sessions a host-only cookie, to avoid exposing it to externally-hosted subdomains of
// getgrist.com. In that case, the sign-in status cookie would remain a 2nd-level domain cookie.
export function signInStatusMiddleware(req: Request, resp: Response, next: NextFunction) {
const mreq = req as RequestWithLogin;
let origSignInStatus: SignInStatus = '';
if (req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie);
origSignInStatus = cookies[signInStatusCookieName] || '';
}
onHeaders(resp, () => {
const newSignInStatus = getSignInStatus(mreq.session);
if (newSignInStatus !== origSignInStatus) {
// If not signed-in any more, set a past date to delete this cookie.
const expires = (newSignInStatus && mreq.session.cookie.expires) || new Date(0);
resp.append('Set-Cookie', cookie.serialize(signInStatusCookieName, newSignInStatus, {
httpOnly: false, // make available to client-side scripts
expires,
domain: getCookieDomain(req),
path: '/',
sameSite: 'lax', // same setting as for grist-sid is fine here.
}));
}
});
next();
}