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, getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise ) { 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, getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise, 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 { 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; // Fetch the doc metadata from HomeDBManager. getDoc(): Promise; // Check access, throw error if the requested level of access isn't available. assertAccess(role: 'viewers'|'editors'|'owners'): Promise; // 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, 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 { return this.linkParameters; } public async getDoc(): Promise { return this._dbManager.getDoc(this._key); } public async assertAccess(role: 'viewers'|'editors'|'owners'): Promise { 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 { 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 = { ...(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(); }