diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 5ef28d75..1393e54d 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -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; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 5515b5f5..c29cc20a 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -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, 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, 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 { diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 7a70efc7..5913a1d2 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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 { + 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 => { - // 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"); diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index ece3f6ea..ef7389f5 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -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; getTag(): string; + sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise; } export interface GristLoginSystem { @@ -51,6 +53,9 @@ export interface GristLoginMiddleware { getSignUpRedirectUrl(req: express.Request, target: URL): Promise; getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise; + // Optional middleware for the GET /login, /signup, and /signin routes. + getLoginOrSignUpMiddleware?(): express.RequestHandler[]; + // Returns arbitrary string for log. addEndpoints(app: express.Express): Promise; } diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index b604f0b2..1f76b503 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -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 diff --git a/stubs/app/server/lib/logins.ts b/stubs/app/server/lib/logins.ts index c60f0168..bceeac2b 100644 --- a/stubs/app/server/lib/logins.ts +++ b/stubs/app/server/lib/logins.ts @@ -7,7 +7,3 @@ export async function getLoginSystem(): Promise { if (saml) { return saml; } return getMinimalLoginSystem(); } - -export function getLoginSubdomain(): string | null { - return null; -}