(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:
George Gevoian
2022-04-01 14:31:24 -07:00
parent 8fdfb02646
commit 6305811ca6
24 changed files with 188 additions and 185 deletions

View File

@@ -24,7 +24,8 @@
*/
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
import {UrlState} from 'app/client/lib/UrlState';
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState,
parseFirstUrlPart} from 'app/common/gristUrls';
import {addOrgToPath} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI';
import isEmpty = require('lodash/isEmpty');
@@ -64,39 +65,40 @@ export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
// Get url for the login page, which will then redirect to nextUrl (current page by default).
// Get url for the login page, which will then redirect to `nextUrl` (current page by default).
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
return _getLoginLogoutUrl('login', nextUrl ?? undefined);
return _getLoginLogoutUrl('login', nextUrl);
}
// Get url for the signup page, which will then redirect to nextUrl (current page by default).
// Get url for the signup page, which will then redirect to `nextUrl` (current page by default).
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signup', nextUrl);
}
// Get url for the logout page, which will then redirect to nextUrl (signed-out page by default).
export function getLogoutUrl(nextUrl: string = getSignedOutUrl()): string {
return _getLoginLogoutUrl('logout', nextUrl);
// Get url for the logout page.
export function getLogoutUrl(): string {
return _getLoginLogoutUrl('logout');
}
// Get url for the login page, which will then redirect to nextUrl (current page by default).
// Get url for the signin page, which will then redirect to `nextUrl` (current page by default).
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signin', nextUrl);
}
// Returns the URL for the "you are signed out" page.
export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; }
// Helper which returns the URL of the current page, except when it's the "/signed-out" page, in
// which case returns the org URL. This is a good URL to use for a post-login redirect.
// Returns the relative URL (i.e. path) of the current page, except when it's the
// "/signed-out" page, in which case it returns the home page ("/").
// This is a good URL to use for a post-login redirect.
function _getCurrentUrl(): string {
return window.location.pathname.endsWith("/signed-out") ? getMainOrgUrl() : window.location.href;
if (window.location.pathname.endsWith('/signed-out')) { return '/'; }
const {pathname, search} = new URL(window.location.href);
return parseFirstUrlPart('o', pathname).path + search;
}
// Helper for getLoginUrl()/getLogoutUrl().
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl?: string): string {
// Returns the URL for the given login page, with 'next' param optionally set.
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + method;
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + page;
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
return startUrl.href;
}

View File

@@ -1,7 +1,7 @@
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {getLoginUrl, urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
import * as css from 'app/client/ui/BillingPageCss';
@@ -499,7 +499,7 @@ export class BillingPage extends Disposable {
// If the user is not logged in and selects the free plan, provide a login link that
// redirects back to the free org.
return css.upgradeBtn('Sign up',
{href: getLoginUrl(getMainOrgUrl())},
{href: getLoginUrl()},
testId('plan-btn')
);
} else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) {

View File

@@ -98,7 +98,7 @@ export class WelcomePage extends Disposable {
`If you already have a Grist account as `,
dom('b', email.get()),
` you can just `,
dom('a', {href: getLoginUrl(urlState().makeUrl({}))}, 'log in'),
dom('a', {href: getLoginUrl()}, 'log in'),
` now. Otherwise, please pick a password.`
),
cssSeparatedLabel('The email address you activated Grist with:'),

View File

@@ -1,31 +0,0 @@
import {parseSubdomain} from 'app/common/gristUrls';
// This interface is used by the standalone login-connect tool for knowing where to redirect to,
// by Client.ts to construct this info, and by CognitoClient to decide what to do.
export interface LoginState {
// Locally-running Grist uses localPort, while hosted uses subdomain. Login-connect uses this to
// redirect back to the localhost or to the subdomain.
localPort?: number;
subdomain?: string;
baseDomain?: string; // the domain with the (left-most) subdomain removed, e.g. ".getgrist.com".
// undefined on localhost.
// Standalone version sets clientId, used later to find the LoginSession. Hosted and dev
// versions rely on the browser cookies instead, specifically on the session cookie.
clientId?: string;
// Hosted and dev versions set redirectUrl and redirect to it when login or logout completes.
// Standalone version omits redirectUrl, and serves a page which closes the window.
redirectUrl?: string;
}
/// Allowed localhost addresses.
export const localhostRegex = /^localhost(?::(\d+))?$/i;
export function getLoginState(reqHost: string): LoginState|null {
const {org, base} = parseSubdomain(reqHost);
const matchPort = localhostRegex.exec(reqHost);
return org ? {subdomain: org, baseDomain: base} :
matchPort ? {localPort: matchPort[1] ? parseInt(matchPort[1], 10) : 80} : null;
}

View File

@@ -2,7 +2,6 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {OpenDocMode} from 'app/common/DocListAPI';
import {EngineCode} from 'app/common/DocumentSettings';
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
import {localhostRegex} from 'app/common/LoginState';
import {LocalPlugin} from 'app/common/plugin';
import {StringUnion} from 'app/common/StringUnion';
import {UIRowId} from 'app/common/UIRowId';
@@ -34,7 +33,7 @@ export type WelcomePage = typeof WelcomePage.type;
export const AccountPage = StringUnion('account');
export type AccountPage = typeof AccountPage.type;
export const LoginPage = StringUnion('signup', 'verified', 'forgot-password');
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
@@ -89,7 +88,7 @@ export interface IGristUrlState {
billingPlan?: string;
billingTask?: BillingTask;
embed?: boolean;
next?: string;
state?: string;
style?: InterfaceStyle;
compare?: string;
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
@@ -302,13 +301,16 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (map.has('signup')) {
state.login = 'signup';
} else if (map.has('login')) {
state.login = 'login';
} else if (map.has('verified')) {
state.login = 'verified';
} else if (map.has('forgot-password')) {
state.login = 'forgot-password';
}
if (sp.has('next')) { state.params!.next = sp.get('next')!; }
if (sp.has('state')) {
state.params!.state = sp.get('state')!;
}
if (sp.has('style')) {
state.params!.style = InterfaceStyle.parse(sp.get('style'));
@@ -407,6 +409,9 @@ export function parseSubdomain(host: string|undefined): {org?: string, base?: st
return {};
}
// Allowed localhost addresses.
const localhostRegex = /^localhost(?::(\d+))?$/i;
/**
* Like parseSubdomain, but throws an error if neither of these cases apply:
* - host can be parsed into a valid subdomain and a valid base domain.
@@ -566,7 +571,7 @@ export function isOrgInPathOnly(host?: string): boolean {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return (gristConfig && gristConfig.pathOnly) || false;
} else {
if (host && host.match(/^localhost(:[0-9]+)?$/)) { return true; }
if (host && host.match(localhostRegex)) { return true; }
return (process.env.GRIST_ORG_IN_PATH === 'true');
}
}

View File

@@ -921,3 +921,12 @@ export function getDistinctValues<T>(values: readonly T[], count: number = Infin
}
return distinct;
}
/**
* Asserts that variable `name` has a non-nullish `value`.
*/
export function assertIsDefined<T>(name: string, value: T): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error(`Expected '${name}' to be defined, but received ${value}`);
}
}

View File

@@ -93,6 +93,9 @@ class DummyDocWorkerMap implements IDocWorkerMap {
},
async close(): Promise<void> {
_permits.clear();
},
getKeyPrefix() {
return formatPermitKey('', prefix);
}
};
this._permitStores.set(prefix, store);
@@ -461,6 +464,9 @@ export class DocWorkerMap implements IDocWorkerMap {
},
async close() {
// nothing to do
},
getKeyPrefix() {
return formatPermitKey('', prefix);
}
};
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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'}})));

View File

@@ -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>;
}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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);
}
/**