(core) Adding GristConnect login system

Summary:
New login system to allow simple SSO flow that is based on Discourse description that is available at:
https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045

Test Plan: New core test.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3418
This commit is contained in:
Jarosław Sadziński
2022-05-18 12:25:14 +02:00
parent cf23a2d1ee
commit 0ab9e4a6a0
16 changed files with 245 additions and 31 deletions

View File

@@ -230,8 +230,10 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
// If we haven't set a maxAge yet, set it now.
if (session && session.cookie && !session.cookie.maxAge) {
session.cookie.maxAge = COOKIE_MAX_AGE;
forceSessionChange(session);
if (COOKIE_MAX_AGE !== null) {
session.cookie.maxAge = COOKIE_MAX_AGE;
forceSessionChange(session);
}
}
// See if we have a profile linked with the active organization already.

View File

@@ -31,7 +31,7 @@ const DISCOURSE_SITE = process.env.DISCOURSE_SITE;
export const Deps = {DISCOURSE_CONNECT_SECRET, DISCOURSE_SITE};
// Calculate payload signature using the given secret.
function calcSignature(payload: string, secret: string) {
export function calcSignature(payload: string, secret: string) {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

View File

@@ -89,6 +89,8 @@ export interface FlexServerOptions {
pluginUrl?: string;
}
const noop: express.RequestHandler = (req, res, next) => next();
export class FlexServer implements GristServer {
public readonly create = create;
public tagChecker: TagChecker;
@@ -508,17 +510,14 @@ export class FlexServer implements GristServer {
this._getSignUpRedirectUrl);
this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this);
} else {
const noop: express.RequestHandler = (req, res, next) => next();
this._userIdMiddleware = noop;
this._trustOriginsMiddleware = noop;
this._docPermissionsMiddleware = (req, res, next) => {
// For standalone single-user Grist, documents are stored on-disk
// with their filename equal to the document title, no document
// aliases are possible, and there is no access control.
// The _docPermissionsMiddleware is a no-op.
// TODO We might no longer have any tests for isSingleUserMode, or modes of operation.
next();
};
// For standalone single-user Grist, documents are stored on-disk
// with their filename equal to the document title, no document
// aliases are possible, and there is no access control.
// The _docPermissionsMiddleware is a no-op.
// TODO We might no longer have any tests for isSingleUserMode, or modes of operation.
this._docPermissionsMiddleware = noop;
this._redirectToLoginWithExceptionsMiddleware = noop;
this._redirectToLoginWithoutExceptionsMiddleware = noop;
this._redirectToLoginUnconditionally = null; // there is no way to log in.
@@ -722,6 +721,9 @@ export class FlexServer implements GristServer {
baseDomain: this._defaultBaseDomain,
});
const forcedLoginMiddleware = process.env.GRIST_FORCE_LOGIN === 'true' ?
this._redirectToLoginWithoutExceptionsMiddleware : noop;
const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
(req, res, next) => next() :
expressWrap(async (req, res, next) => {
@@ -781,6 +783,7 @@ export class FlexServer implements GristServer {
middleware: [
this._redirectToHostMiddleware,
this._userIdMiddleware,
forcedLoginMiddleware,
this._redirectToLoginWithExceptionsMiddleware,
this._redirectToOrgMiddleware,
welcomeNewUser
@@ -789,6 +792,7 @@ export class FlexServer implements GristServer {
// Same as middleware, except without login redirect middleware.
this._redirectToHostMiddleware,
this._userIdMiddleware,
forcedLoginMiddleware,
this._redirectToOrgMiddleware,
welcomeNewUser
],

View File

@@ -1,6 +1,6 @@
import { UserProfile } from 'app/common/UserAPI';
import { GristLoginSystem, GristServer, setUserInSession } from 'app/server/lib/GristServer';
import { Request } from 'express';
import {UserProfile} from 'app/common/UserAPI';
import {GristLoginSystem, GristServer, setUserInSession} from 'app/server/lib/GristServer';
import {Request} from 'express';
/**
* Return a login system that supports a single hard-coded user.
@@ -10,10 +10,10 @@ export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
// no nuance here.
return {
async getMiddleware(gristServer: GristServer) {
async function getLoginRedirectUrl(req: Request, url: URL) {
async function getLoginRedirectUrl(req: Request, url: URL) {
await setUserInSession(req, gristServer, getDefaultProfile());
return url.href;
}
}
return {
getLoginRedirectUrl,
getSignUpRedirectUrl: getLoginRedirectUrl,
@@ -30,7 +30,7 @@ export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
user.isFirstTimeUser = false;
await user.save();
}
return "no-logins";
return 'no-logins';
},
};
},

View File

@@ -1,5 +1,6 @@
import * as session from '@gristlabs/express-session';
import {parseSubdomain} from 'app/common/gristUrls';
import {isNumber} from 'app/common/gutil';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {Sessions} from 'app/server/lib/Sessions';
@@ -12,7 +13,10 @@ import * as shortUUID from "short-uuid";
export const cookieName = process.env.GRIST_SESSION_COOKIE || 'grist_sid';
export const COOKIE_MAX_AGE = 90 * 24 * 60 * 60 * 1000; // 90 days in milliseconds
export const COOKIE_MAX_AGE =
process.env.COOKIE_MAX_AGE === 'none' ? null :
isNumber(process.env.COOKIE_MAX_AGE || '') ? Number(process.env.COOKIE_MAX_AGE) :
90 * 24 * 60 * 60 * 1000; // 90 days in milliseconds
// RedisStore and SqliteStore are expected to provide a set/get interface for sessions.
export interface SessionStore {

View File

@@ -223,6 +223,9 @@ export function pruneAPIResult<T>(data: T, allowedFields?: Set<string>): T {
if (key === 'options' && value === null) { return undefined; }
// Don't prune anything that is explicitly allowed.
if (allowedFields?.has(key)) { return value; }
// User connect id is not used in regular configuration, so we remove it from the response, when
// it's not filled.
if (key === 'connectId' && value === null) { return undefined; }
return INTERNAL_FIELDS.has(key) ? undefined : value;
});
return JSON.parse(output);