(core) Add Grist forgot password page

Summary:
The page isn't yet linked to from anywhere in the UI, but
will be soon, once the new login page is ready. The page
can still be accessed at login-[s].getgrist.com/forgot-password,
and the flow is similar to the one used by Cognito's hosted UI.

Also refactors much of the existing login app code into smaller
files with less duplication, tweaks password validation to be closer
to Cognito's requirements, and polishes various parts of the UI,
like the verified page CSS, and the form inputs.

Test Plan: Browser, server and project tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3296
markdown-cells
George Gevoian 2 years ago
parent aa3fe975e7
commit 9522438967

@ -103,7 +103,7 @@ function _getCurrentUrl(): string {
// Helper for getLoginUrl()/getLogoutUrl().
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl?: string): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = addOrgToPath('', window.location.href) + '/' + method;
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + method;
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
return startUrl.href;
}

@ -26,7 +26,7 @@ export type WelcomePage = typeof WelcomePage.type;
export const AccountPage = StringUnion('account');
export type AccountPage = typeof AccountPage.type;
export const LoginPage = StringUnion('signup', 'verified');
export const LoginPage = StringUnion('signup', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
@ -252,7 +252,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
// the minimum length of a urlId prefix is longer than the maximum length
// of any of the valid keys in the url.
for (const key of map.keys()) {
if (key.length >= MIN_URLID_PREFIX_LENGTH) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
map.set('doc', key);
map.set('slug', map.get(key)!);
map.delete(key);
@ -296,6 +296,8 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.login = 'signup';
} 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')!; }
@ -522,7 +524,7 @@ export function isClient() {
export function getKnownOrg(): string|null {
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return (gristConfig && gristConfig.org) || null;
return (gristConfig && gristConfig.singleOrg) || null;
} else {
return process.env.GRIST_SINGLE_ORG || null;
}
@ -545,7 +547,7 @@ export function getSingleOrg(): string|null {
/**
* Returns true if org must be encoded in path, not in domain. Determined from
* gristConfig on the client. On on the server returns true if the host is
* gristConfig on the client. On the server, returns true if the host is
* supplied and is 'localhost', or if GRIST_ORG_IN_PATH is set to 'true'.
*/
export function isOrgInPathOnly(host?: string): boolean {

@ -41,14 +41,13 @@ import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier';
import * as log from 'app/server/lib/log';
import {getLoginSubdomain, getLoginSystem} from 'app/server/lib/logins';
import {getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit';
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import {adaptServerUrl, addOrgToPath, addOrgToPathIfNeeded, addPermit, getScope,
optStringParam, RequestWithGristInfo, stringParam,
TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {adaptServerUrl, addOrgToPath, addPermit, getOrgUrl, getScope, optStringParam,
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
import {getDatabaseUrl} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
@ -291,6 +290,11 @@ export class FlexServer implements GristServer {
return this._notifier;
}
public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> {
if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); }
return this._sendAppPage(req, resp, options);
}
public addLogging() {
if (this._check('logging')) { return; }
if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
@ -836,26 +840,22 @@ export class FlexServer implements GristServer {
this.addComm();
/**
* Gets the URL to redirect back to after successful sign-up or login.
* 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> => {
// If a "next" query param is present, check if it's safe to use, and return it if so.
const next = optStringParam(mreq.query.next);
if (next) {
if (!(await this._hosts.isSafeRedirectUrl(next))) {
throw new ApiError('Invalid redirect URL', 400);
}
return 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);
}
// Otherwise, return the "/" path on the request host. If the host is the Grist
// login host, return the "/" path on the merged org instead.
const loginSubdomain = getLoginSubdomain();
return !loginSubdomain || mreq.org !== loginSubdomain ?
getOrgUrl(mreq) : this.getMergedOrgUrl(mreq);
return next;
};
async function redirectToLoginOrSignup(
@ -863,12 +863,6 @@ export class FlexServer implements GristServer {
) {
const mreq = req as RequestWithLogin;
// If sign-up request is to the Grist login host, serve a sign-up page instead of redirecting.
const loginSubdomain = getLoginSubdomain();
if (signUp && loginSubdomain && mreq.org === loginSubdomain) {
return this._sendAppPage(req, resp, {path: 'login.html', status: 200, config: {}});
}
// 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.
forceSessionChange(mreq.session);
@ -881,9 +875,13 @@ export class FlexServer implements GristServer {
resp.redirect(await getRedirectUrl(req, new URL(await getNextUrl(mreq))));
}
this.app.get('/login', expressWrap(redirectToLoginOrSignup.bind(this, false)));
this.app.get('/signup', expressWrap(redirectToLoginOrSignup.bind(this, true)));
this.app.get('/signin', expressWrap(redirectToLoginOrSignup.bind(this, null)));
const middleware = 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)));
if (allowTestLogin()) {
// This is an endpoint for the dev environment that lets you log in as anyone.
@ -935,16 +933,14 @@ export class FlexServer implements GristServer {
}
this.app.get('/logout', expressWrap(async (req, resp) => {
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
// If 'next' param is missing, redirect to "/" on our requested hostname.
const next = optStringParam(req.query.next) || getOrgUrl(req);
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(next));
const mreq = req as RequestWithLogin;
const scopedSession = this._sessions.getOrCreateSessionFromRequest(mreq);
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(await getNextUrl(mreq)));
// 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 = (req as any).session;
const expressSession = mreq.session;
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
await scopedSession.clearScopedSession(req);
// TODO: limit cache clearing to specific user.
@ -957,10 +953,6 @@ export class FlexServer implements GristServer {
this.app.get('/signed-out', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}})));
// Add a static "verified" page. This is where an email verification link will land, on success.
this.app.get('/verified', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'login.html', status: 200, config: {}})));
const comment = await this._loginMiddleware.addEndpoints(this.app);
this.info.push(['loginMiddlewareComment', comment]);
@ -1714,11 +1706,6 @@ function trustOriginHandler(req: express.Request, res: express.Response, next: e
}
}
// Get url to the org associated with the request.
function getOrgUrl(req: express.Request) {
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
}
// Set Cache-Control header to "no-cache"
function noCaching(req: express.Request, res: express.Response, next: express.NextFunction) {
res.header("Cache-Control", "no-cache");

@ -11,6 +11,7 @@ import { ICreate } from 'app/server/lib/ICreate';
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
import { INotifier } from 'app/server/lib/INotifier';
import { IPermitStore } from 'app/server/lib/Permit';
import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
import { Sessions } from 'app/server/lib/Sessions';
import * as express from 'express';
@ -39,6 +40,7 @@ export interface GristServer {
getNotifier(): INotifier;
getDocTemplate(): Promise<DocTemplate>;
getTag(): string;
sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;
}
export interface GristLoginSystem {
@ -51,6 +53,9 @@ export interface GristLoginMiddleware {
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[];
// Returns arbitrary string for log.
addEndpoints(app: express.Express): Promise<string>;
}

@ -64,6 +64,13 @@ export function addOrgToPath(req: RequestWithOrg, path: string): string {
return req.org ? `/o/${req.org}${path}` : path;
}
/**
* Get url to the org associated with the request.
*/
export function getOrgUrl(req: Request) {
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
}
/**
* Returns true for requests from permitted origins. For such requests, an
* "Access-Control-Allow-Origin" header is added to the response. Vary: Origin

@ -7,7 +7,3 @@ export async function getLoginSystem(): Promise<GristLoginSystem> {
if (saml) { return saml; }
return getMinimalLoginSystem();
}
export function getLoginSubdomain(): string | null {
return null;
}

Loading…
Cancel
Save