feat: allow external SSO methods to log into teams as long as emails match (#3813)

* wip

* wip

* fix comments

* better separation of conerns

* fix up tests

* fix semantics

* fixup tsc

* fix some tests

* the old semantics were easier to use

* add db:reset to scripts

* explicitly throw for unauthorized external authorization

* fix minor bug

* add additional tests for user creator and team creator

* yank the email matching logic out of teamcreator

* renaming

* fix type and test errors

* adds test to ensure that accountProvisioner works with email matching

* remove only

* fix comments

* recreate changes to allow self hosted to make teams
This commit is contained in:
Nan Yu
2022-07-24 07:55:30 -04:00
committed by GitHub
parent 24170e8684
commit 870d9ed41e
11 changed files with 322 additions and 165 deletions

View File

@@ -0,0 +1,259 @@
import { sequelize } from "@server/database/sequelize";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import {
DomainNotAllowedError,
InvalidAuthenticationError,
InviteRequiredError,
} from "@server/errors";
import { Event, Team, User, UserAuthentication } from "@server/models";
type UserProvisionerResult = {
user: User;
isNewUser: boolean;
authentication: UserAuthentication | null;
};
type Props = {
name: string;
email: string;
username?: string;
isAdmin?: boolean;
avatarUrl?: string | null;
teamId: string;
emailMatchOnly?: boolean;
ip: string;
authentication: {
authenticationProviderId: string;
providerId: string;
scopes: string[];
accessToken?: string;
refreshToken?: string;
expiresAt?: Date;
};
};
export default async function userProvisioner({
name,
email,
username,
isAdmin,
emailMatchOnly,
avatarUrl,
teamId,
authentication,
ip,
}: Props): Promise<UserProvisionerResult> {
const { providerId, authenticationProviderId, ...rest } = authentication;
const auth = await UserAuthentication.findOne({
where: {
providerId,
},
include: [
{
model: User,
as: "user",
where: { teamId },
required: true,
},
],
});
// Someone has signed in with this authentication before, we just
// want to update the details instead of creating a new record
if (auth) {
const { user } = auth;
// We found an authentication record that matches the user id, but it's
// associated with a different authentication provider, (eg a different
// hosted google domain). This is possible in Google Auth when moving domains.
// In the future we may auto-migrate these.
if (auth.authenticationProviderId !== authenticationProviderId) {
throw new Error(
`User authentication ${providerId} already exists for ${auth.authenticationProviderId}, tried to assign to ${authenticationProviderId}`
);
}
if (user) {
await user.update({
email,
username,
});
await auth.update(rest);
return {
user,
authentication: auth,
isNewUser: false,
};
}
// We found an authentication record, but the associated user was deleted or
// otherwise didn't exist. Cleanup the auth record and proceed with creating
// a new user. See: https://github.com/outline/outline/issues/2022
await auth.destroy();
}
// A `user` record may exist even if there is no existing authentication record.
// This is either an invite or a user that's external to the team
const existingUser = await User.scope([
"withAuthentications",
"withTeam",
]).findOne({
where: {
// Email from auth providers may be capitalized and we should respect that
// however any existing invites will always be lowercased.
email: email.toLowerCase(),
teamId,
},
});
// We have an existing user, so we need to update it with our
// new details and count this as a new user creation.
if (existingUser) {
// A `user` record might exist in the form of an invite.
// An invite is a shell user record with no authentication method
// that's never been active before.
const isInvite = existingUser.isInvited;
const auth = await sequelize.transaction(async (transaction) => {
if (isInvite) {
await Event.create(
{
name: "users.create",
actorId: existingUser.id,
userId: existingUser.id,
teamId: existingUser.teamId,
data: {
name,
},
ip,
},
{
transaction,
}
);
}
// Regardless, create a new authentication record
// against the existing user (user can auth with multiple SSO providers)
// Update user's name and avatar based on the most recently added provider
await existingUser.update(
{
name,
avatarUrl,
lastActiveAt: new Date(),
lastActiveIp: ip,
},
{
transaction,
}
);
// We don't want to associate a user auth with the auth provider
// if we're doing a simple email match, so early return here
if (emailMatchOnly) {
return null;
}
return await existingUser.$create<UserAuthentication>(
"authentication",
authentication,
{
transaction,
}
);
});
if (isInvite) {
const inviter = await existingUser.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: existingUser.name,
teamUrl: existingUser.team.url,
});
}
}
return {
user: existingUser,
authentication: auth,
isNewUser: isInvite,
};
} else if (emailMatchOnly) {
// There's no existing invite or user that matches the external auth email
// This is simply unauthorized
throw InvalidAuthenticationError();
}
//
// No auth, no user this is an entirely new sign in.
//
const transaction = await User.sequelize!.transaction();
try {
const team = await Team.findByPk(teamId, {
attributes: ["defaultUserRole", "inviteRequired", "id"],
transaction,
});
// If the team settings are set to require invites, and there's no existing user record,
// throw an error and fail user creation.
if (team?.inviteRequired) {
throw InviteRequiredError();
}
// If the team settings do not allow this domain,
// throw an error and fail user creation.
const domain = email.split("@")[1];
if (team && !(await team.isDomainAllowed(domain))) {
throw DomainNotAllowedError();
}
const defaultUserRole = team?.defaultUserRole;
const user = await User.create(
{
name,
email,
username,
isAdmin: typeof isAdmin === "boolean" && isAdmin,
isViewer: isAdmin === true ? false : defaultUserRole === "viewer",
teamId,
avatarUrl,
service: null,
authentications: [authentication],
},
{
include: "authentications",
transaction,
}
);
await Event.create(
{
name: "users.create",
actorId: user.id,
userId: user.id,
teamId: user.teamId,
data: {
name: user.name,
},
ip,
},
{
transaction,
}
);
await transaction.commit();
return {
user,
authentication: user.authentications[0],
isNewUser: true,
};
} catch (err) {
await transaction.rollback();
throw err;
}
}