mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
7a85aaa7a1
Summary: Adds a handful of new telemetry events, and makes a few tweaks to allow for better organization of telemetry. Test Plan: Manual. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D4100
744 lines
29 KiB
TypeScript
744 lines
29 KiB
TypeScript
import {ApiError} from 'app/common/ApiError';
|
|
import {OpenDocMode} from 'app/common/DocListAPI';
|
|
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
|
import {ActivationState} from 'app/common/gristUrls';
|
|
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
|
import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
|
|
import {UserOptions} from 'app/common/UserAPI';
|
|
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 {GristServer} from 'app/server/lib/GristServer';
|
|
import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain,
|
|
cookieName as sessionCookieName} from 'app/server/lib/gristSessions';
|
|
import {makeId} from 'app/server/lib/idUtils';
|
|
import log from 'app/server/lib/log';
|
|
import {IPermitStore, Permit} from 'app/server/lib/Permit';
|
|
import {AccessTokenInfo} from 'app/server/lib/AccessTokens';
|
|
import {allowHost, getOriginUrl, isEnvironmentAllowedHost, optStringParam} from 'app/server/lib/requestUtils';
|
|
import * as cookie from 'cookie';
|
|
import {NextFunction, Request, RequestHandler, Response} from 'express';
|
|
import {IncomingMessage} from 'http';
|
|
import 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;
|
|
accessToken?: AccessTokenInfo;
|
|
altSessionId?: string; // a session id for use in trigger formulas and granular access rules
|
|
activation?: ActivationState;
|
|
}
|
|
|
|
/**
|
|
* 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, or may be passed in.
|
|
* 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;
|
|
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;
|
|
}
|
|
}
|
|
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,
|
|
options: {
|
|
gristServer: GristServer,
|
|
skipSession?: boolean,
|
|
getProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>,
|
|
},
|
|
req: Request, res: Response, next: NextFunction) {
|
|
const mreq = req as RequestWithLogin;
|
|
let profile: UserProfile|undefined;
|
|
|
|
// We support multiple method of authentication. This flag gets set once
|
|
// we need not try any more. Specifically, it is used to avoid processing
|
|
// anything else after setting an access token, for simplicity in reasoning
|
|
// about this case.
|
|
let authDone: boolean = false;
|
|
|
|
let hasApiKey: boolean = false;
|
|
|
|
// Support providing an access token via an `auth` query parameter.
|
|
// This is useful for letting the browser load assets like image
|
|
// attachments.
|
|
const auth = optStringParam(mreq.query.auth, 'auth');
|
|
if (auth) {
|
|
const tokens = options.gristServer.getAccessTokens();
|
|
const token = await tokens.verify(auth);
|
|
mreq.accessToken = token;
|
|
// Once an accessToken is supplied, we don't consider anything else.
|
|
// User is treated as anonymous apart from having an accessToken.
|
|
authDone = true;
|
|
}
|
|
|
|
// Now, check for an apiKey
|
|
if (!authDone && 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;
|
|
hasApiKey = true;
|
|
}
|
|
}
|
|
|
|
// Special permission header for internal housekeeping tasks
|
|
if (!authDone && 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 a header that would trigger a CORS pre-flight request, either:
|
|
// - X-Requested-With: XMLHttpRequest
|
|
// - 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
|
|
// - Content-Type: application/json
|
|
// - https://www.directdefense.com/csrf-in-the-age-of-json/
|
|
// This is trivial for legitimate web clients to do, and an obstacle to
|
|
// nefarious ones.
|
|
if (
|
|
!mreq.userId &&
|
|
!(mreq.xhr || mreq.get("content-type") === "application/json") &&
|
|
!['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)
|
|
) {
|
|
return res.status(401).json({
|
|
error: "Unauthenticated requests require one of the headers" +
|
|
"'Content-Type: application/json' or 'X-Requested-With: XMLHttpRequest'"
|
|
});
|
|
}
|
|
|
|
// For some configurations, the user profile can be determined from the request.
|
|
// 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) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 (!authDone && !skipSession) {
|
|
// 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) || isEnvironmentAllowedHost(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) {
|
|
if (COOKIE_MAX_AGE !== null) {
|
|
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, '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);
|
|
const userOptions: UserOptions = {};
|
|
if (sessionUser?.profile?.loginMethod === 'Email + Password') {
|
|
// Link the session authSubject, if present, to the user. This has no effect
|
|
// if the user already has an authSubject set in the db.
|
|
userOptions.authSubject = sessionUser.authSubject;
|
|
}
|
|
// 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, {userOptions});
|
|
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?.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 userOptions: UserOptions = {};
|
|
if (profile?.loginMethod === 'Email + Password') {
|
|
// Link the session authSubject, if present, to the user. This has no effect
|
|
// if the user already has an authSubject set in the db.
|
|
userOptions.authSubject = sessionUser.authSubject;
|
|
}
|
|
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile, userOptions});
|
|
if (user) {
|
|
mreq.user = user;
|
|
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)];
|
|
}
|
|
|
|
if (mreq.userId) {
|
|
if (mreq.user?.options?.locale) {
|
|
mreq.language = mreq.user.options.locale;
|
|
// This is a synchronous call (as it was configured with initImmediate: false).
|
|
mreq.i18n.changeLanguage(mreq.language).catch(() => {});
|
|
}
|
|
}
|
|
|
|
const meta = {
|
|
customHostSession,
|
|
method: mreq.method,
|
|
host: mreq.get('host'),
|
|
path: mreq.path,
|
|
org: mreq.org,
|
|
email: mreq.user?.loginEmail,
|
|
userId: mreq.userId,
|
|
altSessionId: mreq.altSessionId,
|
|
};
|
|
log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta);
|
|
if (hasApiKey) {
|
|
options.gristServer.getTelemetry().logEvent(mreq, 'apiUsage', {
|
|
full: {
|
|
method: mreq.method,
|
|
userId: mreq.userId,
|
|
userAgent: mreq.headers['user-agent'],
|
|
},
|
|
});
|
|
}
|
|
|
|
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(getOriginUrl(req) + 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,
|
|
gristServer: GristServer,
|
|
urlId: string
|
|
): Promise<DocAuthResult> {
|
|
if (!mreq.docAuth) {
|
|
let effectiveUserId = getUserId(mreq);
|
|
if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {
|
|
effectiveUserId = dbManager.getPreviewerUserId();
|
|
}
|
|
|
|
// A permit with a token gives us the userId associated with that token.
|
|
const tokenObj = mreq.accessToken;
|
|
if (tokenObj) {
|
|
effectiveUserId = tokenObj.userId;
|
|
}
|
|
|
|
mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org});
|
|
|
|
if (tokenObj) {
|
|
// Sanity check: does the current document match the document the token is
|
|
// for? If not, fail.
|
|
if (!mreq.docAuth.docId || mreq.docAuth.docId !== tokenObj.docId) {
|
|
throw new ApiError('token misuse', 401);
|
|
}
|
|
// Limit access to read-only if specified.
|
|
if (tokenObj.readOnly) {
|
|
mreq.docAuth = {...mreq.docAuth, access: getWeakestRole('viewers', mreq.docAuth.access)};
|
|
}
|
|
}
|
|
|
|
// A permit with a user set to the anonymous user and linked to this document
|
|
// gets updated to full access.
|
|
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;
|
|
}
|
|
|
|
export interface DocAuthorizerOptions {
|
|
dbManager: HomeDBManager;
|
|
key: DocAuthKey;
|
|
openMode: OpenDocMode;
|
|
linkParameters: Record<string, string>;
|
|
userRef?: string|null;
|
|
docAuth?: DocAuthResult;
|
|
profile?: UserProfile;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Handle authorization for a single document and user.
|
|
*
|
|
*/
|
|
export class DocAuthorizer implements Authorizer {
|
|
public readonly openMode: OpenDocMode;
|
|
public readonly linkParameters: Record<string, string>;
|
|
constructor(
|
|
private _options: DocAuthorizerOptions
|
|
) {
|
|
this.openMode = _options.openMode;
|
|
this.linkParameters = _options.linkParameters;
|
|
}
|
|
|
|
public getUserId(): number {
|
|
return this._options.key.userId;
|
|
}
|
|
|
|
public getUser(): FullUser|null {
|
|
return this._options.profile ? {
|
|
id: this.getUserId(),
|
|
ref: this._options.userRef,
|
|
...this._options.profile
|
|
} : null;
|
|
}
|
|
|
|
public getDocId(): string {
|
|
// We've been careful to require urlId === docId, see DocManager.
|
|
return this._options.key.urlId;
|
|
}
|
|
|
|
public getLinkParameters(): Record<string, string> {
|
|
return this.linkParameters;
|
|
}
|
|
|
|
public async getDoc(): Promise<Document> {
|
|
return this._options.dbManager.getDoc(this._options.key);
|
|
}
|
|
|
|
public async assertAccess(role: 'viewers'|'editors'|'owners'): Promise<void> {
|
|
const docAuth = await this._options.dbManager.getDocAuthCached(this._options.key);
|
|
this._options.docAuth = docAuth;
|
|
assertAccess(role, docAuth, {openMode: this.openMode});
|
|
}
|
|
|
|
public getCachedAuth(): DocAuthResult {
|
|
if (!this._options.docAuth) { throw Error('no cached authentication'); }
|
|
return this._options.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);
|
|
}
|
|
}
|
|
|
|
if (role === 'owners' && docAuth.access !== 'owners') {
|
|
throw new ErrorWithCode("AUTH_NO_OWNER", "No owner 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();
|
|
}
|