mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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')),
|
||||
|
||||
@@ -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'),
|
||||
))
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
22
app/gen-server/migration/1652277549983-UserConnectId.ts
Normal file
22
app/gen-server/migration/1652277549983-UserConnectId.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user