gristlabs_grist-core/app/server/lib/BrowserSession.ts
Paul Fitzpatrick accd640078 (core) add a user.SessionID value for trigger formulas and granular access rules
Summary:
This makes a `user.SessionID` value available in information about the user, for use with trigger formulas and granular access rules. The ID should be constant within a browser session for anonymous user. For logged in users it simply reflects their user id.

This ID makes it possible to write access rules and trigger formulas that allow different anonymous users to create, view, and edit their own records in a document.

For example, you could have a brain-storming document for puns, and allow anyone to add to it (without logging in), letting people edit their own records, but not showing the records to others until they are approved by a moderator. Without something like this, we could only let anonymous people add one field of a record, and not have a secure way to let them edit that field or others in the same record.

Also adds a `user.IsLoggedIn` flag in passing.

Test Plan: Added a test, updated tests. The test added is a mini-moderation doc, don't use it for real because it allows users to edit their entries after a moderator has approved them.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3273
2022-02-22 12:50:43 -05:00

302 lines
11 KiB
TypeScript

import {normalizeEmail} from 'app/common/emails';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {SessionStore} from 'app/server/lib/gristSessions';
import * as log from 'app/server/lib/log';
import {fromCallback} from 'app/server/lib/serverUtils';
import {Request} from 'express';
// Part of a session related to a single user.
export interface SessionUserObj {
// a grist-internal identify for the user, if known.
userId?: number;
// The user profile object.
profile?: UserProfile;
/**
* Unix time in seconds of the last successful login. Includes security
* verification prompts, such as those for configuring MFA preferences.
*/
lastLoginTimestamp?: number;
// [UNUSED] Authentication provider string indicating the login method used.
authProvider?: string;
// [UNUSED] Login ID token used to access AWS services.
idToken?: string;
// Login access token used to access other AWS services.
accessToken?: string;
// Login refresh token used to retrieve new ID and access tokens.
refreshToken?: string;
// State for SAML-mediated logins.
samlNameId?: string;
samlSessionIndex?: string;
}
// Session state maintained for a particular browser. It is identified by a cookie. There may be
// several browser windows/tabs that share this cookie and this state.
export interface SessionObj {
// Session cookie.
// This is marked optional to reflect the reality of pre-existing code.
cookie?: any;
// A list of users we have logged in as.
// This is optional since the session may already exist.
users?: SessionUserObj[];
// map from org to an index into users[]
// This is optional since the session may already exist.
orgToUser?: {[org: string]: number};
// This gets set to encourage express-session to set a cookie. Was a boolean in the past.
alive?: number;
altSessionId?: string; // An ID unique to the session, but which isn't related
// to the session id used to lookup the cookie. This ID
// is suitable for embedding in documents that allows
// anonymous editing (e.g. to allow the user to edit
// something they just added, without allowing the suer
// to edit other people's contributions).
}
// Make an artificial change to a session to encourage express-session to set a cookie.
export function forceSessionChange(session: SessionObj) {
session.alive = Number(session.alive || 0) + 1;
}
// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.
// The values are:
// - "S": the user is signed in once; in this case an automatic signin can be unambiguous and seamless.
// - "M": the user is signed in multiple times.
// - "": the user is not signed in.
export type SignInStatus = 'S'|'M'|'';
export function getSignInStatus(sessionObj: SessionObj|null): SignInStatus {
const length = sessionObj?.users?.length;
return !length ? "" : (length === 1 ? 'S' : 'M');
}
/**
* Extract the available user profiles from the session.
*
*/
export function getSessionProfiles(session: SessionObj): UserProfile[] {
if (!session.users) { return []; }
return session.users.filter(user => user && user.profile).map(user => user.profile!);
}
/**
*
* Gets user profile from the session for a given org, returning null if no profile is
* found specific to that org.
*
*/
export function getSessionUser(session: SessionObj, org: string,
userSelector: string): SessionUserObj|null {
if (!session.users) { return null; }
if (!session.users.length) { return null; }
if (userSelector) {
for (const user of session.users) {
if (user.profile?.email.toLowerCase() === userSelector.toLowerCase()) { return user; }
}
}
if (session.orgToUser && session.orgToUser[org] !== undefined &&
session.users.length > session.orgToUser[org]) {
return session.users[session.orgToUser[org]] || null;
}
return null;
}
/**
*
* Record which user to use by default for a given org in future.
* This method mutates the session object passed to it. It does not save it,
* that is up to the caller.
*
*/
export function linkOrgWithEmail(session: SessionObj, email: string, org: string): SessionUserObj {
if (!session.users || !session.orgToUser) { throw new Error("Session not set up"); }
email = normalizeEmail(email);
for (let i = 0; i < session.users.length; i++) {
const iUser = session.users[i];
if (iUser && iUser.profile && normalizeEmail(iUser.profile.email) === email) {
session.orgToUser[org] = i;
return iUser;
}
}
throw new Error("Failed to link org with email");
}
/**
*
* This is a view of the session object, for a single organization (the "scope").
*
* Local caching is disabled in an environment where there is a home server (or we are
* the home server). In hosted Grist, per-instance caching would be a problem.
*
* We retain local caching for situations with a single server - especially electron.
*
*/
export class ScopedSession {
private _sessionCache?: SessionObj;
private _live: boolean; // if set, never cache session in memory.
private _altSessionId?: string;
/**
* Create an interface to the session identified by _sessionId, in the store identified
* by _sessionStore, for the organization identified by _scope.
*/
constructor(private _sessionId: string,
private _sessionStore: SessionStore,
private _org: string,
private _userSelector: string) {
// Assume we need to skip cache in a hosted environment. GRIST_HOST is always set there.
// TODO: find a cleaner way to configure this flag.
this._live = Boolean(process.env.GRIST_HOST || process.env.GRIST_HOSTED);
}
/**
* Get the user entry from the current session.
* @param prev: if supplied, this session object is used rather than querying the session again.
* @return the user entry
*/
public async getScopedSession(prev?: SessionObj): Promise<SessionUserObj> {
const session = prev || await this._getSession();
return getSessionUser(session, this._org, this._userSelector) || {};
}
// Retrieves the user profile from the session.
public async getSessionProfile(prev?: SessionObj): Promise<UserProfile|null> {
return (await this.getScopedSession(prev)).profile || null;
}
// Updates a user profile. The session may have multiple profiles associated with different
// email addresses. This will update the one with a matching email address, or add a new one.
// This is mainly used to know which emails are logged in in this session; fields like name and
// picture URL come from the database instead.
public async updateUserProfile(req: Request, profile: UserProfile|null): Promise<void> {
profile ? await this.updateUser(req, {profile}) : await this.clearScopedSession(req);
}
/**
* Updates the properties of the current session user.
*
* @param {Partial<SessionUserObj>} newProps New property values to set.
*/
public async updateUser(req: Request, newProps: Partial<SessionUserObj>): Promise<void> {
await this.operateOnScopedSession(req, async user => ({...user, ...newProps}));
}
/**
*
* This performs an operation on the session object, limited to a single user entry. The state of that
* user entry before and after the operation are returned. LoginSession relies heavily on this method,
* to determine whether the change made by an operation merits certain follow-up work.
*
* @param op: Operation to perform. Given a single user entry, and should return a single user entry.
* It is fine to modify the supplied user entry in place.
*
* @return a pair [prev, current] with the state of the single user entry before and after the operation.
*
*/
public async operateOnScopedSession(req: Request, op: (user: SessionUserObj) =>
Promise<SessionUserObj>): Promise<[SessionUserObj, SessionUserObj]> {
const session = await this._getSession();
const user = await this.getScopedSession(session);
const oldUser = JSON.parse(JSON.stringify(user)); // Old version to compare against.
const newUser = await op(JSON.parse(JSON.stringify(user))); // Modify a scratch version.
if (Object.keys(newUser).length === 0) {
await this.clearScopedSession(req, session);
} else {
await this._updateScopedSession(req, newUser, session);
}
return [oldUser, newUser];
}
/**
* This clears the current user entry from the session.
* @param prev: if supplied, this session object is used rather than querying the session again.
*/
public async clearScopedSession(req: Request, prev?: SessionObj): Promise<void> {
const session = prev || await this._getSession();
this._clearUser(session);
await this._setSession(req, session);
}
public getAltSessionId(): string | undefined {
return this._altSessionId;
}
/**
* Read the state of the session.
*/
private async _getSession(): Promise<SessionObj> {
if (this._sessionCache) { return this._sessionCache; }
const session = ((await this._sessionStore.getAsync(this._sessionId)) || {}) as SessionObj;
if (!this._live) { this._sessionCache = session; }
this._altSessionId = session.altSessionId;
return session;
}
/**
* Set the session to the supplied object.
*/
private async _setSession(req: Request, session: SessionObj): Promise<void> {
try {
await this._sessionStore.setAsync(this._sessionId, session);
if (!this._live) { this._sessionCache = session; }
const reqSession = (req as any).session;
if (reqSession?.reload) {
await fromCallback(cb => reqSession.reload(cb));
}
} catch (e) {
// (I've copied this from old code, not sure if continuing after a session save error is
// something existing code depends on?)
// Report and keep going. This ensures that the session matches what's in the sessionStore.
log.error(`ScopedSession[${this._sessionId}]: Error updating sessionStore: ${e}`);
}
}
/**
* Update the session with the supplied user entry, replacing anything for that user already there.
* @param user: user entry to insert in session
* @param prev: if supplied, this session object is used rather than querying the session again.
*
*/
private async _updateScopedSession(req: Request, user: SessionUserObj, prev?: SessionObj): Promise<void> {
const profile = user.profile;
if (!profile) {
throw new Error("No profile available");
}
// We used to also check profile.email_verified, but we no longer create UserProfile objects
// unless the email is verified, so this check is no longer needed.
if (!profile.email) {
throw new Error("Profile has no email address");
}
const session = prev || await this._getSession();
if (!session.users) { session.users = []; }
if (!session.orgToUser) { session.orgToUser = {}; }
let index = session.users.findIndex(u => Boolean(u.profile && u.profile.email === profile.email));
if (index < 0) { index = session.users.length; }
session.orgToUser[this._org] = index;
session.users[index] = user;
await this._setSession(req, session);
}
/**
* This clears all user logins (not just the current login).
* In future, we may want to be able to log in and out selectively, slack style,
* but right now it seems confusing.
*/
private _clearUser(session: SessionObj): void {
session.users = [];
session.orgToUser = {};
}
}