Ctrl K
ComunidadGuías…

server/commands/userProvisioner.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { InferCreationAttributes } from "sequelize";
import { Op, Sequelize } from "sequelize";
import type { UserRole } from "@shared/types";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import {
  DomainNotAllowedError,
  InvalidAuthenticationError,
  InviteRequiredError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import {
  AuthenticationProvider,
  Team,
  User,
  UserAuthentication,
} from "@server/models";
import { sequelize } from "@server/storage/database";
import type { APIContext } from "@server/types";
import { UserFlag } from "@server/models/User";
import UploadUserAvatarTask from "@server/queues/tasks/UploadUserAvatarTask";

type UserProvisionerResult = {
  user: User;
  isNewUser: boolean;
  /** Whether this sign-in accepted an existing invite. */
  isInvite: boolean;
  authentication: UserAuthentication | null;
};

type Props = {
  /** The displayed name of the user */
  name: string;
  /** The email address of the user */
  email: string;
  /**
   * Whether the provider has verified the user owns the email address.
   * Matching an existing account by email only happens when explicitly true.
   */
  emailVerified?: boolean;
  /** The display name of the authentication provider, eg "Google". */
  authenticationProviderName?: string;
  /** The language of the user, if known */
  language?: string;
  /** The role for new user, Member if none is provided */
  role?: UserRole;
  /** The public url of an image representing the user */
  avatarUrl?: string | null;
  /**
   * The internal ID of the team that is being logged into based on the
   * subdomain that the request came from, if any.
   */
  teamId: string;
  /** Bundle of props related to the current external provider authentication */
  authentication?: {
    authenticationProviderId: string;
    /** External identifier of the user in the authentication provider  */
    providerId: string | number;
    /** The scopes granted by the access token */
    scopes: string[];
    /** The token provided by the authentication provider */
    accessToken?: string;
    /** The refresh token provided by the authentication provider */
    refreshToken?: string;
    /** The timestamp when the access token expires */
    expiresAt?: Date;
  };
};

export default async function userProvisioner(
  ctx: APIContext,
  {
    name,
    email,
    emailVerified,
    authenticationProviderName,
    role,
    language,
    avatarUrl,
    teamId,
    authentication,
  }: Props
): Promise<UserProvisionerResult> {
  // The provider being signed in with, external user identifiers are only
  // unique within the namespace of a single provider so it must be known
  // before an existing authentication record can be matched.
  const provider = authentication
    ? await AuthenticationProvider.findOne({
        where: {
          id: authentication.authenticationProviderId,
          teamId,
        },
      })
    : undefined;

  if (authentication && !provider) {
    throw InvalidAuthenticationError();
  }

  const auth =
    authentication && provider
      ? await UserAuthentication.findOne({
          where: {
            providerId: String(authentication.providerId),
          },
          include: [
            {
              model: User,
              as: "user",
              where: { teamId },
              required: true,
            },
            {
              // Restrict matching to the same type of provider, an identifier
              // from one provider must never match a record from another.
              model: AuthenticationProvider,
              as: "authenticationProvider",
              where: { name: provider.name },
              required: true,
            },
          ],
        })
      : undefined;

  // Someone has signed in with this authentication before, we just
  // want to update the details instead of creating a new record
  if (auth && authentication) {
    const { providerId, authenticationProviderId, ...rest } = authentication;
    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 or Discord server). This can happen when moving
    // domains or changing server configurations. Auto-migrate to the new provider.
    if (auth.authenticationProviderId !== authenticationProviderId) {
      Logger.info(
        "authentication",
        "Migrating user to new authentication provider",
        {
          userId: user?.id,
          providerId,
          fromAuthenticationProviderId: auth.authenticationProviderId,
          toAuthenticationProviderId: authenticationProviderId,
        }
      );
      await auth.update({ authenticationProviderId });
    }

    if (user) {
      if (avatarUrl && !user.getFlag(UserFlag.AvatarUpdated)) {
        await new UploadUserAvatarTask().schedule({
          userId: user.id,
          avatarUrl,
        });
      }
      // Overwrite the stored email when the provider verified it
      if (emailVerified === true) {
        await user.update({ email });
      }
      await auth.update(rest);

      return {
        user,
        authentication: auth,
        isNewUser: false,
        isInvite: 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(["withTeam"]).findOne({
    where: {
      // Email from auth providers may be capitalized
      [Op.and]: [
        Sequelize.where(
          Sequelize.fn("lower", Sequelize.col("email")),
          email.toLowerCase()
        ),
      ],
      teamId,
    },
  });

  const team = await Team.scope("withDomains").findByPk(teamId, {
    attributes: ["defaultUserRole", "inviteRequired", "id"],
  });

  // Unverified emails cannot match an existing account or pass allow listed domains
  if (emailVerified !== true && (existingUser || team?.allowedDomains.length)) {
    const providerName = authenticationProviderName ?? "your identity provider";
    throw InvalidAuthenticationError(
      `Your email address has not been verified by ${providerName}. Please verify your email and try signing in again.`
    );
  }

  // 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 userAuth = await sequelize.transaction(async (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: ctx.ip,
        },
        {
          transaction,
        }
      );

      // Only need to associate the authentication with the user if there is one.
      if (!authentication) {
        return null;
      }

      return await existingUser.$create<UserAuthentication>(
        "authentication",
        authentication,
        {
          transaction,
        }
      );
    });

    if (avatarUrl && !existingUser.getFlag(UserFlag.AvatarUpdated)) {
      await new UploadUserAvatarTask().schedule({
        userId: existingUser.id,
        avatarUrl,
      });
    }

    if (isInvite) {
      const inviter = await existingUser.$get("invitedBy");
      if (inviter) {
        await new InviteAcceptedEmail({
          to: inviter.email,
          language: inviter.language,
          inviterId: inviter.id,
          invitedName: existingUser.name,
          teamUrl: existingUser.team.url,
        }).schedule();
      }
    }

    return {
      user: existingUser,
      authentication: userAuth,
      isNewUser: isInvite,
      isInvite,
    };
  } else if (!authentication && !team?.allowedDomains.length) {
    // There's no existing invite or user that matches the external auth email
    // and there is no possibility of matching an allowed domain.
    throw InvalidAuthenticationError(
      "No matching user for email or allowed domain"
    );
  }

  //
  // No auth, no user – this is an entirely new sign in.
  //

  const transaction = await User.sequelize!.transaction();

  try {
    // 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) {
      Logger.info("authentication", "Sign in without invitation", {
        teamId: team.id,
        email,
      });
      throw InviteRequiredError();
    }

    // If the team settings do not allow this domain,
    // throw an error and fail user creation.
    if (team && !(await team.isDomainAllowed(email))) {
      throw DomainNotAllowedError();
    }

    const user = await User.createWithCtx(
      ctx,
      {
        name,
        email,
        language,
        role: role ?? team?.defaultUserRole,
        teamId,
        avatarUrl,
        authentications: authentication ? [authentication] : [],
        lastActiveAt: new Date(),
        lastActiveIp: ctx.ip,
      } as Partial<InferCreationAttributes<User>>,
      undefined,
      {
        include: "authentications",
      }
    );
    await transaction.commit();
    return {
      user,
      authentication: user.authentications[0],
      isNewUser: true,
      isInvite: false,
    };
  } catch (err) {
    await transaction.rollback();
    throw err;
  }
}