mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
226 lines
8.2 KiB
TypeScript
226 lines
8.2 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';
|
||
|
|
||
|
// 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. When updated, all clients get a message with the update.
|
||
|
profile?: UserProfile;
|
||
|
|
||
|
// Authentication provider string indicating the login method used.
|
||
|
authProvider?: string;
|
||
|
|
||
|
// 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;
|
||
|
}
|
||
|
|
||
|
// 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.
|
||
|
alive?: boolean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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): SessionUserObj|null {
|
||
|
if (!session.users) { return null; }
|
||
|
if (!session.users.length) { return null; }
|
||
|
|
||
|
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 enviroment 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.
|
||
|
|
||
|
/**
|
||
|
* 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) {
|
||
|
// 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 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(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(session);
|
||
|
} else {
|
||
|
await this._updateScopedSession(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(prev?: SessionObj): Promise<void> {
|
||
|
const session = prev || await this._getSession();
|
||
|
this._clearUser(session);
|
||
|
await this._setSession(session);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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; }
|
||
|
return session;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set the session to the supplied object.
|
||
|
*/
|
||
|
private async _setSession(session: SessionObj): Promise<void> {
|
||
|
try {
|
||
|
await this._sessionStore.setAsync(this._sessionId, session);
|
||
|
if (!this._live) { this._sessionCache = session; }
|
||
|
} 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(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(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 = {};
|
||
|
}
|
||
|
}
|