mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add authSubject and authProvider to sessions
Summary: This also updates Authorizer to link the authSubject to Grist users if not previously linked. Linked subjects are now used as the username for password-based logins, instead of emails, which remain as a fallback. Test Plan: Existing tests, and tested login flows manually. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3356
This commit is contained in:
parent
14f7e30e6f
commit
859c593448
@ -135,6 +135,8 @@ export interface Document extends DocumentProperties {
|
|||||||
export interface UserOptions {
|
export interface UserOptions {
|
||||||
// Whether signing in with Google is allowed. Defaults to true if unset.
|
// Whether signing in with Google is allowed. Defaults to true if unset.
|
||||||
allowGoogleLogin?: boolean;
|
allowGoogleLogin?: boolean;
|
||||||
|
// The "sub" (subject) from the JWT issued by the password-based authentication provider.
|
||||||
|
authSubject?: string;
|
||||||
// Whether user is a consultant. Consultant users can be added to sites
|
// Whether user is a consultant. Consultant users can be added to sites
|
||||||
// without being counted for billing. Defaults to false if unset.
|
// without being counted for billing. Defaults to false if unset.
|
||||||
isConsultant?: boolean;
|
isConsultant?: boolean;
|
||||||
|
@ -203,6 +203,12 @@ export interface DocAuthResult {
|
|||||||
cachedDoc?: Document; // For cases where stale info is ok.
|
cachedDoc?: Document; // For cases where stale info is ok.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetUserOptions {
|
||||||
|
manager?: EntityManager;
|
||||||
|
profile?: UserProfile;
|
||||||
|
userOptions?: UserOptions;
|
||||||
|
}
|
||||||
|
|
||||||
// Represent a DocAuthKey as a string. The format is "<urlId>:<org> <userId>".
|
// Represent a DocAuthKey as a string. The format is "<urlId>:<org> <userId>".
|
||||||
// flushSingleDocAuthCache() depends on this format.
|
// flushSingleDocAuthCache() depends on this format.
|
||||||
function stringifyDocAuthKey(key: DocAuthKey): string {
|
function stringifyDocAuthKey(key: DocAuthKey): string {
|
||||||
@ -402,7 +408,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
public async testClearUserPrefs(emails: string[]) {
|
public async testClearUserPrefs(emails: string[]) {
|
||||||
return await this._connection.transaction(async manager => {
|
return await this._connection.transaction(async manager => {
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const user = await this.getUserByLogin(email, undefined, manager);
|
const user = await this.getUserByLogin(email, {manager});
|
||||||
if (user) {
|
if (user) {
|
||||||
await manager.delete(Pref, {userId: user.id});
|
await manager.delete(Pref, {userId: user.id});
|
||||||
}
|
}
|
||||||
@ -493,16 +499,16 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// for an email key conflict failure. This is in case our transaction conflicts with a peer
|
// for an email key conflict failure. This is in case our transaction conflicts with a peer
|
||||||
// doing the same thing. This is quite likely if the first page visited by a previously
|
// doing the same thing. This is quite likely if the first page visited by a previously
|
||||||
// unseen user fires off multiple api calls.
|
// unseen user fires off multiple api calls.
|
||||||
public async getUserByLoginWithRetry(email: string, profile: UserProfile): Promise<User|undefined> {
|
public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
|
||||||
try {
|
try {
|
||||||
return await this.getUserByLogin(email, profile);
|
return await this.getUserByLogin(email, options);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'QueryFailedError' && e.detail &&
|
if (e.name === 'QueryFailedError' && e.detail &&
|
||||||
e.detail.match(/Key \(email\)=[^ ]+ already exists/)) {
|
e.detail.match(/Key \(email\)=[^ ]+ already exists/)) {
|
||||||
// This is a postgres-specific error message. This problem cannot arise in sqlite,
|
// This is a postgres-specific error message. This problem cannot arise in sqlite,
|
||||||
// because we have to serialize sqlite transactions in any case to get around a typeorm
|
// because we have to serialize sqlite transactions in any case to get around a typeorm
|
||||||
// limitation.
|
// limitation.
|
||||||
return await this.getUserByLogin(email, profile);
|
return await this.getUserByLogin(email, options);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -513,15 +519,12 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
* Fetches a user record based on an email address. If a user record already
|
* Fetches a user record based on an email address. If a user record already
|
||||||
* exists linked to the email address supplied, that is the record returned.
|
* exists linked to the email address supplied, that is the record returned.
|
||||||
* Otherwise a fresh record is created, linked to the supplied email address.
|
* Otherwise a fresh record is created, linked to the supplied email address.
|
||||||
* The name supplied is used to create this fresh record - otherwise it is
|
* The supplied `options` are used when creating a fresh record, or updating
|
||||||
* ignored.
|
* unset/outdated fields of an existing record.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public async getUserByLogin(
|
public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
|
||||||
email: string,
|
const {manager: transaction, profile, userOptions} = options;
|
||||||
profile?: UserProfile,
|
|
||||||
transaction?: EntityManager
|
|
||||||
): Promise<User|undefined> {
|
|
||||||
const normalizedEmail = normalizeEmail(email);
|
const normalizedEmail = normalizeEmail(email);
|
||||||
const userByLogin = await this._runInTransaction(transaction, async manager => {
|
const userByLogin = await this._runInTransaction(transaction, async manager => {
|
||||||
let needUpdate = false;
|
let needUpdate = false;
|
||||||
@ -580,6 +583,11 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
login.displayEmail = email;
|
login.displayEmail = email;
|
||||||
needUpdate = true;
|
needUpdate = true;
|
||||||
}
|
}
|
||||||
|
if (!user.options?.authSubject && userOptions?.authSubject) {
|
||||||
|
// Link subject from password-based authentication provider if not previously linked.
|
||||||
|
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
||||||
|
needUpdate = true;
|
||||||
|
}
|
||||||
if (needUpdate) {
|
if (needUpdate) {
|
||||||
login.user = user;
|
login.user = user;
|
||||||
await manager.save([user, login]);
|
await manager.save([user, login]);
|
||||||
@ -2879,7 +2887,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// get or create user - with retry, since there'll be a race to create the
|
// get or create user - with retry, since there'll be a race to create the
|
||||||
// user if a bunch of servers start simultaneously and the user doesn't exist
|
// user if a bunch of servers start simultaneously and the user doesn't exist
|
||||||
// yet.
|
// yet.
|
||||||
const user = await this.getUserByLoginWithRetry(profile.email, profile);
|
const user = await this.getUserByLoginWithRetry(profile.email, {profile});
|
||||||
if (user) { id = this._specialUserIds[profile.email] = user.id; }
|
if (user) { id = this._specialUserIds[profile.email] = user.id; }
|
||||||
}
|
}
|
||||||
if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }
|
if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }
|
||||||
@ -2979,7 +2987,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
const emailMap = delta.users;
|
const emailMap = delta.users;
|
||||||
const emails = Object.keys(emailMap);
|
const emails = Object.keys(emailMap);
|
||||||
const emailUsers = await Promise.all(
|
const emailUsers = await Promise.all(
|
||||||
emails.map(async email => await this.getUserByLogin(email, undefined, transaction))
|
emails.map(async email => await this.getUserByLogin(email, {manager: transaction}))
|
||||||
);
|
);
|
||||||
emails.forEach((email, i) => {
|
emails.forEach((email, i) => {
|
||||||
const userIdAffected = emailUsers[i]!.id;
|
const userIdAffected = emailUsers[i]!.id;
|
||||||
|
@ -3,6 +3,7 @@ import {OpenDocMode} from 'app/common/DocListAPI';
|
|||||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
||||||
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
||||||
import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
|
import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
|
||||||
|
import {UserOptions} from 'app/common/UserAPI';
|
||||||
import {Document} from 'app/gen-server/entity/Document';
|
import {Document} from 'app/gen-server/entity/Document';
|
||||||
import {User} from 'app/gen-server/entity/User';
|
import {User} from 'app/gen-server/entity/User';
|
||||||
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
@ -243,8 +244,14 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
// Modify request session object to link the current org with our choice of
|
// Modify request session object to link the current org with our choice of
|
||||||
// profile. Express-session will save this change.
|
// profile. Express-session will save this change.
|
||||||
sessionUser = linkOrgWithEmail(session, option.email, mreq.org);
|
sessionUser = linkOrgWithEmail(session, option.email, mreq.org);
|
||||||
|
const userOptions: UserOptions = {};
|
||||||
|
if (sessionUser?.profile?.loginMethod === 'Email + Password') {
|
||||||
|
// Link the session authSubject, if present, to the user. This has no effect
|
||||||
|
// if the user already has an authSubject set in the db.
|
||||||
|
userOptions.authSubject = sessionUser.authSubject;
|
||||||
|
}
|
||||||
// In this special case of initially linking a profile, we need to look up the user's info.
|
// In this special case of initially linking a profile, we need to look up the user's info.
|
||||||
mreq.user = await dbManager.getUserByLogin(option.email);
|
mreq.user = await dbManager.getUserByLogin(option.email, {userOptions});
|
||||||
mreq.userId = option.id;
|
mreq.userId = option.id;
|
||||||
mreq.userIsAuthorized = true;
|
mreq.userIsAuthorized = true;
|
||||||
} else {
|
} else {
|
||||||
@ -263,12 +270,18 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
profile = sessionUser && sessionUser.profile || undefined;
|
profile = sessionUser?.profile ?? undefined;
|
||||||
|
|
||||||
// If we haven't computed a userId yet, check for one using an email address in the profile.
|
// If we haven't computed a userId yet, check for one using an email address in the profile.
|
||||||
// A user record will be created automatically for emails we've never seen before.
|
// A user record will be created automatically for emails we've never seen before.
|
||||||
if (profile && !mreq.userId) {
|
if (profile && !mreq.userId) {
|
||||||
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
|
const userOptions: UserOptions = {};
|
||||||
|
if (profile?.loginMethod === 'Email + Password') {
|
||||||
|
// Link the session authSubject, if present, to the user. This has no effect
|
||||||
|
// if the user already has an authSubject set in the db.
|
||||||
|
userOptions.authSubject = sessionUser.authSubject;
|
||||||
|
}
|
||||||
|
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile, userOptions});
|
||||||
if (user) {
|
if (user) {
|
||||||
mreq.user = user;
|
mreq.user = user;
|
||||||
mreq.userId = user.id;
|
mreq.userId = user.id;
|
||||||
@ -280,7 +293,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
if (!mreq.userId) {
|
if (!mreq.userId) {
|
||||||
profile = getRequestProfile(mreq);
|
profile = getRequestProfile(mreq);
|
||||||
if (profile) {
|
if (profile) {
|
||||||
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
|
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
|
||||||
if(user) {
|
if(user) {
|
||||||
mreq.user = user;
|
mreq.user = user;
|
||||||
mreq.users = [profile];
|
mreq.users = [profile];
|
||||||
@ -290,7 +303,6 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// If no userId has been found yet, fall back on anonymous.
|
// If no userId has been found yet, fall back on anonymous.
|
||||||
if (!mreq.userId) {
|
if (!mreq.userId) {
|
||||||
const anon = dbManager.getAnonymousUser();
|
const anon = dbManager.getAnonymousUser();
|
||||||
|
@ -19,9 +19,17 @@ export interface SessionUserObj {
|
|||||||
*/
|
*/
|
||||||
lastLoginTimestamp?: number;
|
lastLoginTimestamp?: number;
|
||||||
|
|
||||||
// [UNUSED] Authentication provider string indicating the login method used.
|
/**
|
||||||
|
* The authentication provider. (Typically the JWT "iss".)
|
||||||
|
*/
|
||||||
authProvider?: string;
|
authProvider?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifier for the user from the authentication provider. (Typically
|
||||||
|
* the JWT "sub".)
|
||||||
|
*/
|
||||||
|
authSubject?: string;
|
||||||
|
|
||||||
// [UNUSED] Login ID token used to access AWS services.
|
// [UNUSED] Login ID token used to access AWS services.
|
||||||
idToken?: string;
|
idToken?: string;
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
|
|||||||
// If working without a login system, make sure default user exists.
|
// If working without a login system, make sure default user exists.
|
||||||
const dbManager = gristServer.getHomeDBManager();
|
const dbManager = gristServer.getHomeDBManager();
|
||||||
const profile = getDefaultProfile();
|
const profile = getDefaultProfile();
|
||||||
const user = await dbManager.getUserByLoginWithRetry(profile.email, profile);
|
const user = await dbManager.getUserByLoginWithRetry(profile.email, {profile});
|
||||||
if (user) {
|
if (user) {
|
||||||
// No need to survey this user!
|
// No need to survey this user!
|
||||||
user.isFirstTimeUser = false;
|
user.isFirstTimeUser = false;
|
||||||
|
@ -21,6 +21,7 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?
|
|||||||
const INTERNAL_FIELDS = new Set([
|
const INTERNAL_FIELDS = new Set([
|
||||||
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
|
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
|
||||||
'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
||||||
|
'authSubject',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -78,10 +78,8 @@ export async function main() {
|
|||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error('need GRIST_DEFAULT_EMAIL to create site');
|
throw new Error('need GRIST_DEFAULT_EMAIL to create site');
|
||||||
}
|
}
|
||||||
const user = await db.getUserByLogin(email, {
|
const profile = {email, name: email};
|
||||||
email,
|
const user = await db.getUserByLogin(email, {profile});
|
||||||
name: email,
|
|
||||||
});
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// This should not happen.
|
// This should not happen.
|
||||||
throw new Error('failed to create GRIST_DEFAULT_EMAIL user');
|
throw new Error('failed to create GRIST_DEFAULT_EMAIL user');
|
||||||
|
@ -32,7 +32,7 @@ export function configForUser(username: string): AxiosRequestConfig {
|
|||||||
export async function createUser(dbManager: HomeDBManager, name: string): Promise<Organization> {
|
export async function createUser(dbManager: HomeDBManager, name: string): Promise<Organization> {
|
||||||
const username = name.toLowerCase();
|
const username = name.toLowerCase();
|
||||||
const email = `${username}@getgrist.com`;
|
const email = `${username}@getgrist.com`;
|
||||||
const user = await dbManager.getUserByLogin(email, {email, name});
|
const user = await dbManager.getUserByLogin(email, {profile: {email, name}});
|
||||||
if (!user) { throw new Error('failed to create user'); }
|
if (!user) { throw new Error('failed to create user'); }
|
||||||
user.apiKey = `api_key_for_${username}`;
|
user.apiKey = `api_key_for_${username}`;
|
||||||
await user.save();
|
await user.save();
|
||||||
|
@ -1760,10 +1760,8 @@ export async function addSupportUserIfPossible() {
|
|||||||
if (!server.isExternalServer() && process.env.TEST_SUPPORT_API_KEY) {
|
if (!server.isExternalServer() && process.env.TEST_SUPPORT_API_KEY) {
|
||||||
// Make sure we have a test support user.
|
// Make sure we have a test support user.
|
||||||
const dbManager = await server.getDatabase();
|
const dbManager = await server.getDatabase();
|
||||||
const user = await dbManager.getUserByLoginWithRetry('support@getgrist.com', {
|
const profile = {email: 'support@getgrist.com', name: 'Support'};
|
||||||
email: 'support@getgrist.com',
|
const user = await dbManager.getUserByLoginWithRetry('support@getgrist.com', {profile});
|
||||||
name: 'Support',
|
|
||||||
});
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Failed to create test support user');
|
throw new Error('Failed to create test support user');
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user