mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add new Grist login page
Summary: Adds a new Grist login page to the login app, and replaces the server-side Cognito Google Sign-In flow with Google's own OAuth flow. Test Plan: Browser and server tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3332
This commit is contained in:
@@ -282,7 +282,9 @@ export class ScopedSession {
|
||||
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));
|
||||
let index = session.users.findIndex(u => {
|
||||
return Boolean(u.profile && normalizeEmail(u.profile.email) === normalizeEmail(profile.email));
|
||||
});
|
||||
if (index < 0) { index = session.users.length; }
|
||||
session.orgToUser[this._org] = index;
|
||||
session.users[index] = user;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {ApiError} from 'app/common/ApiError';
|
||||
import {BrowserSettings} from 'app/common/BrowserSettings';
|
||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {getLoginState, LoginState} from 'app/common/LoginState';
|
||||
import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
|
||||
import {User} from 'app/gen-server/entity/User';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
@@ -75,7 +74,6 @@ export class Client {
|
||||
private _destroyTimer: NodeJS.Timer|null = null;
|
||||
private _destroyed: boolean = false;
|
||||
private _websocket: any;
|
||||
private _loginState: LoginState|null = null;
|
||||
private _org: string|null = null;
|
||||
private _profile: UserProfile|null = null;
|
||||
private _userId: number|null = null;
|
||||
@@ -95,9 +93,6 @@ export class Client {
|
||||
|
||||
public toString() { return `Client ${this.clientId} #${this._counter}`; }
|
||||
|
||||
// Returns the LoginState object that's encoded and passed via login pages to login-connect.
|
||||
public getLoginState(): LoginState|null { return this._loginState; }
|
||||
|
||||
public setCounter(counter: string) {
|
||||
this._counter = counter;
|
||||
}
|
||||
@@ -112,8 +107,6 @@ export class Client {
|
||||
|
||||
public setConnection(websocket: any, reqHost: string, browserSettings: BrowserSettings) {
|
||||
this._websocket = websocket;
|
||||
// Set this._loginState, used by CognitoClient to construct login/logout URLs.
|
||||
this._loginState = getLoginState(reqHost);
|
||||
this.browserSettings = browserSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {BillingTask} from 'app/common/BillingAPI';
|
||||
import {delay} from 'app/common/delay';
|
||||
import {DocCreationInfo} from 'app/common/DocListAPI';
|
||||
@@ -305,8 +304,7 @@ export class FlexServer implements GristServer {
|
||||
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
|
||||
// Add a timestamp token that matches exactly the formatting of non-morgan logs.
|
||||
morganLogger.token('logTime', (req: Request) => log.timestamp());
|
||||
// Add an optional gristInfo token that can replace the url, if the url is sensitive
|
||||
// (this is the case for some cognito login urls).
|
||||
// Add an optional gristInfo token that can replace the url, if the url is sensitive.
|
||||
morganLogger.token('gristInfo', (req: RequestWithGristInfo) =>
|
||||
req.gristInfo || req.originalUrl || req.url);
|
||||
morganLogger.token('host', (req: express.Request) => req.get('host'));
|
||||
@@ -850,49 +848,32 @@ export class FlexServer implements GristServer {
|
||||
// should be factored out of it.
|
||||
this.addComm();
|
||||
|
||||
/**
|
||||
* Gets the URL to redirect back to after successful sign-up, login, or logout.
|
||||
*
|
||||
* Note that in the test env, this will redirect further.
|
||||
*/
|
||||
const getNextUrl = async (mreq: RequestWithLogin): Promise<string> => {
|
||||
const next = optStringParam(mreq.query.next);
|
||||
|
||||
// If a "next" query param isn't present, return the URL of the request org.
|
||||
if (next === undefined) { return getOrgUrl(mreq); }
|
||||
|
||||
// Check that the "next" param has a valid host (native or custom) before returning it.
|
||||
if (!(await this._hosts.isSafeRedirectUrl(next))) {
|
||||
throw new ApiError('Invalid redirect URL', 400);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
async function redirectToLoginOrSignup(
|
||||
this: FlexServer, signUp: boolean|null, req: express.Request, resp: express.Response,
|
||||
) {
|
||||
const mreq = req as RequestWithLogin;
|
||||
|
||||
// This will ensure that express-session will set our cookie if it hasn't already -
|
||||
// we'll need it when we come back from Cognito.
|
||||
// we'll need it when we redirect back.
|
||||
forceSessionChange(mreq.session);
|
||||
// Redirect to the requested URL after successful login.
|
||||
const nextPath = optStringParam(req.query.next);
|
||||
const nextUrl = new URL(getOrgUrl(req, nextPath));
|
||||
if (signUp === null) {
|
||||
// Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the
|
||||
// user has ever logged in on this browser.
|
||||
signUp = (mreq.session.users === undefined);
|
||||
}
|
||||
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
|
||||
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
|
||||
resp.redirect(await getRedirectUrl(req, nextUrl));
|
||||
}
|
||||
|
||||
const middleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?
|
||||
const signinMiddleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?
|
||||
this._loginMiddleware.getLoginOrSignUpMiddleware() :
|
||||
[];
|
||||
|
||||
this.app.get('/login', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
||||
this.app.get('/signup', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
||||
this.app.get('/signin', ...middleware, expressWrap(redirectToLoginOrSignup.bind(this, null)));
|
||||
this.app.get('/login', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, false)));
|
||||
this.app.get('/signup', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, true)));
|
||||
this.app.get('/signin', ...signinMiddleware, expressWrap(redirectToLoginOrSignup.bind(this, null)));
|
||||
|
||||
if (allowTestLogin()) {
|
||||
// This is an endpoint for the dev environment that lets you log in as anyone.
|
||||
@@ -943,15 +924,18 @@ export class FlexServer implements GristServer {
|
||||
}));
|
||||
}
|
||||
|
||||
this.app.get('/logout', expressWrap(async (req, resp) => {
|
||||
const mreq = req as RequestWithLogin;
|
||||
const scopedSession = this._sessions.getOrCreateSessionFromRequest(mreq);
|
||||
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(await getNextUrl(mreq)));
|
||||
const logoutMiddleware = this._loginMiddleware.getLogoutMiddleware ?
|
||||
this._loginMiddleware.getLogoutMiddleware() :
|
||||
[];
|
||||
this.app.get('/logout', ...logoutMiddleware, expressWrap(async (req, resp) => {
|
||||
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
|
||||
const signedOutUrl = new URL(getOrgUrl(req) + 'signed-out');
|
||||
const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl);
|
||||
|
||||
// Clear session so that user needs to log in again at the next request.
|
||||
// SAML logout in theory uses userSession, so clear it AFTER we compute the URL.
|
||||
// Express-session will save these changes.
|
||||
const expressSession = mreq.session;
|
||||
const expressSession = (req as RequestWithLogin).session;
|
||||
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
|
||||
await scopedSession.clearScopedSession(req);
|
||||
// TODO: limit cache clearing to specific user.
|
||||
@@ -959,8 +943,8 @@ export class FlexServer implements GristServer {
|
||||
resp.redirect(redirectUrl);
|
||||
}));
|
||||
|
||||
// Add a static "signed-out" page. This is where logout typically lands (after redirecting
|
||||
// through Cognito or SAML).
|
||||
// Add a static "signed-out" page. This is where logout typically lands (e.g. after redirecting
|
||||
// through SAML).
|
||||
this.app.get('/signed-out', expressWrap((req, resp) =>
|
||||
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}})));
|
||||
|
||||
|
||||
@@ -51,10 +51,10 @@ export interface GristLoginMiddleware {
|
||||
getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
||||
getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;
|
||||
getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;
|
||||
|
||||
// Optional middleware for the GET /login, /signup, and /signin routes.
|
||||
getLoginOrSignUpMiddleware?(): express.RequestHandler[];
|
||||
|
||||
// Optional middleware for the GET /logout route.
|
||||
getLogoutMiddleware?(): express.RequestHandler[];
|
||||
// Returns arbitrary string for log.
|
||||
addEndpoints(app: express.Express): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UserProfile } from 'app/common/UserAPI';
|
||||
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
|
||||
import { fromCallback } from 'app/server/lib/serverUtils';
|
||||
import { Request } from 'express';
|
||||
import {UserProfile} from 'app/common/UserAPI';
|
||||
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
|
||||
import {fromCallback} from 'app/server/lib/serverUtils';
|
||||
import {Request} from 'express';
|
||||
|
||||
/**
|
||||
* Return a login system that supports a single hard-coded user.
|
||||
|
||||
@@ -49,6 +49,9 @@ export interface IPermitStore {
|
||||
|
||||
// Close down the permit store.
|
||||
close(): Promise<void>;
|
||||
|
||||
// Get the permit key prefix.
|
||||
getKeyPrefix(): string;
|
||||
}
|
||||
|
||||
export interface IPermitStores {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
|
||||
import { Request } from 'express';
|
||||
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
|
||||
import {Request} from 'express';
|
||||
|
||||
/**
|
||||
* Return a login system for testing. Just enough to use the test/login endpoint
|
||||
@@ -9,9 +9,7 @@ export async function getTestLoginSystem(): Promise<GristLoginSystem> {
|
||||
return {
|
||||
async getMiddleware(gristServer: GristServer) {
|
||||
async function getLoginRedirectUrl(req: Request, url: URL) {
|
||||
// The "gristlogin" query parameter does nothing except make tests
|
||||
// that expect hosted cognito happy (they check for gristlogin in url).
|
||||
const target = new URL(gristServer.getHomeUrl(req, 'test/login?gristlogin=1'));
|
||||
const target = new URL(gristServer.getHomeUrl(req, 'test/login'));
|
||||
target.searchParams.append('next', url.href);
|
||||
return target.href || url.href;
|
||||
}
|
||||
|
||||
@@ -131,27 +131,6 @@ export class Hosts {
|
||||
return this._redirectHost.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `url` either points to a native Grist host (e.g. foo.getgrist.com),
|
||||
* or a custom host for an existing Grist org.
|
||||
*/
|
||||
public async isSafeRedirectUrl(url: string): Promise<boolean> {
|
||||
const {host, protocol} = new URL(url);
|
||||
if (!['http:', 'https:'].includes(protocol)) { return false; }
|
||||
|
||||
switch (this._getHostType(host)) {
|
||||
case 'native': { return true; }
|
||||
case 'custom': {
|
||||
const org = await mapGetOrSet(this._host2org, host, async () => {
|
||||
const o = await this._dbManager.connection.manager.findOne(Organization, {host});
|
||||
return o?.domain ?? undefined;
|
||||
});
|
||||
return org !== undefined;
|
||||
}
|
||||
default: { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this._host2org.clear();
|
||||
this._org2host.clear();
|
||||
|
||||
@@ -68,8 +68,8 @@ export function addOrgToPath(req: RequestWithOrg, path: string): string {
|
||||
/**
|
||||
* Get url to the org associated with the request.
|
||||
*/
|
||||
export function getOrgUrl(req: Request) {
|
||||
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
|
||||
export function getOrgUrl(req: Request, path: string = '/') {
|
||||
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user