(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

@@ -91,7 +91,7 @@ export class AccountWidget extends Disposable {
}
const users = this._appModel.topAppModel.users;
const isExternal = user?.loginMethod === 'External';
return [
cssUserInfo(
createUserImage(user, 'large'),
@@ -138,7 +138,7 @@ export class AccountWidget extends Disposable {
cssOtherEmail(_user.email, testId('usermenu-other-email')),
);
}),
menuItemLink({href: getLoginUrl()}, "Add Account", testId('dm-add-account')),
isExternal ? null : menuItemLink({href: getLoginUrl()}, "Add Account", testId('dm-add-account')),
],
menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')),

View File

@@ -1,5 +1,5 @@
import {AppModel} from 'app/client/models/AppModel';
import {getLoginUrl, urlState} from 'app/client/models/gristUrlState';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
@@ -24,6 +24,8 @@ export function createErrPage(appModel: AppModel) {
* Creates a page to show that the user has no access to this org.
*/
export function createForbiddenPage(appModel: AppModel, message?: string) {
const isAnonym = () => !appModel.currentValidUser;
const isExternal = () => appModel.currentValidUser?.loginMethod === 'External';
return pagePanelsError(appModel, 'Access denied', [
dom.domComputed(appModel.currentValidUser, user => user ? [
cssErrorText(message || "You do not have access to this organization's documents."),
@@ -32,12 +34,14 @@ export function createForbiddenPage(appModel: AppModel, message?: string) {
] : [
// This page is not normally shown because a logged out user with no access will get
// redirected to log in. But it may be seen if a user logs out and returns to a cached
// version of this page.
// version of this page or is an external user (connected through GristConnect).
cssErrorText("Sign in to access this organization's documents."),
]),
cssButtonWrap(bigPrimaryButtonLink(
appModel.currentValidUser ? 'Add account' : 'Sign in',
{href: getLoginUrl()},
isExternal() ? 'Go to main page' :
isAnonym() ? 'Sign in' :
'Add account',
{href: isExternal() ? getMainOrgUrl() : getLoginUrl()},
testId('error-signin'),
))
]);

View File

@@ -4,7 +4,8 @@ export interface UserProfile {
name: string;
picture?: string|null; // when present, a url to a public image of unspecified dimensions.
anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized).
loginMethod?: 'Google'|'Email + Password';
connectId?: string|null, // used by GristConnect to identify user in external provider.
loginMethod?: 'Google'|'Email + Password'|'External';
}
// User profile including user id. All information in it should

View File

@@ -47,6 +47,9 @@ export class User extends BaseEntity {
@Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true})
public options: UserOptions | null;
@Column({name: 'connect_id', type: String, nullable: true})
public connectId: string | null;
/**
* Get user's email. Returns undefined if logins has not been joined, or no login
* is available

View File

@@ -476,6 +476,60 @@ export class HomeDBManager extends EventEmitter {
return result;
}
/**
* Ensures that user with external id exists and updates its profile and email if necessary.
*
* @param profile External profile
*/
public async ensureExternalUser(profile: UserProfile) {
await this._connection.transaction(async manager => {
// First find user by the connectId from the profile
const existing = await manager.findOne(User, { connectId: profile.connectId}, {relations: ["logins"]});
// If a user does not exist, create it with data from the external profile.
if (!existing) {
const newUser = await this.getUserByLoginWithRetry(profile.email, {
profile,
manager
});
if (!newUser) {
throw new ApiError("Unable to create user", 500);
}
// No need to survey this user.
newUser.isFirstTimeUser = false;
await newUser.save();
} else {
// Else update profile and login information from external profile.
let updated = false;
let login: Login = existing.logins[0]!;
const properEmail = normalizeEmail(profile.email);
if (properEmail !== existing.loginEmail) {
login = login ?? new Login();
login.email = properEmail;
login.displayEmail = profile.email;
existing.logins.splice(0, 1, login);
login.user = existing;
updated = true;
}
if (profile?.name && profile?.name !== existing.name) {
existing.name = profile.name;
updated = true;
}
if (profile?.picture && profile?.picture !== existing.picture) {
existing.picture = profile.picture;
updated = true;
}
if (updated) {
await manager.save([existing, login]);
}
}
});
}
public async updateUser(userId: number, props: UserProfileChange): Promise<void> {
let isWelcomed: boolean = false;
let user: User|undefined;
@@ -601,6 +655,12 @@ export class HomeDBManager extends EventEmitter {
login.displayEmail = profile.email;
needUpdate = true;
}
if (profile?.connectId && profile?.connectId !== user.connectId) {
user.connectId = profile.connectId;
needUpdate = true;
}
if (!login.displayEmail) {
// Save some kind of display email if we don't have anything at all for it yet.
// This could be coming from how someone wrote it in a UserManager dialog, for

View File

@@ -0,0 +1,22 @@
import {MigrationInterface, QueryRunner, TableColumn, TableIndex} from "typeorm";
export class UserConnectId1652277549983 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn("users", new TableColumn({
name: "connect_id",
type: 'varchar',
isNullable: true,
isUnique: true,
}));
await queryRunner.createIndex("users", new TableIndex({
name: "users_connect_id",
columnNames: ["connect_id"],
isUnique: true
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropIndex("users", "users_connect_id");
await queryRunner.dropColumn("users", "connect_id");
}
}

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