polygon-software / flox

2 stars 1 forks source link

application specific: choose correct route #466

Open github-actions[bot] opened 1 year ago

github-actions[bot] commented 1 year ago

application specific: choose correct route

https://github.com/polygon-software/flox/blob/4d186fe59f2032a9ae87c9743e434fb43c7c4b76/frontend/src/flox/modules/auth/services/auth.service.ts#L637


import { useApolloClient } from '@vue/apollo-composable';
import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';
import { CognitoUser, CognitoUserSession } from 'amazon-cognito-identity-js';
import cloneDeep from 'lodash/cloneDeep';
import { QVueGlobals, useQuasar } from 'quasar';

import { i18n } from 'boot/i18n';
import Env from 'src/env';
import AdminCreatedUser from 'src/flox/modules/auth/data/types/AdminCreatedUser';
import { showSuccessNotification } from 'src/tools/notification.tool';
import UserEntity from 'src/flox/modules/auth/entities/user.entity';

import { useAuthStore } from '../stores/auth.store';
import ROUTES from '../../../../router/routes';
import ErrorService from '../../../../services/ErrorService';
import RouterService from '../../../../services/RouterService';
import ForgotPasswordDialog from '../components/dialogs/ForgotPasswordDialog.vue';
import ChangePasswordDialog from '../components/dialogs/ChangePasswordDialog.vue';
import EmailConfirmationDialog from '../components/dialogs/EmailConfirmationDialog.vue';
import QrCodeDialog from '../components/dialogs/QrCodeDialog.vue';
import ResetPasswordDialog from '../components/dialogs/ResetPasswordDialog.vue';
import MFADialog from '../components/dialogs/MFADialog.vue';
import DELIVERY_MEDIUMS from '../../../enum/DELIVERY_MEDIUMS';
import ROLE from '../../../enum/USER_ROLES';

import {
  adminCreateUser,
  disableUser,
  enableUser,
  signup,
} from './user.service';

const userNotDefinedError = i18n.global.t('errors.user_not_defined');

/**
 * This is a service that is used globally throughout the application for maintaining authentication state as well as
 * signing up, logging in, logging out, changing passwords, and more.
 */
export default class AuthenticationService {
  // Application info
  appName: string;

  // Quasar instance
  $q: QVueGlobals;

  // Error handler service
  $errorService: ErrorService;

  // Router service
  $routerService: RouterService;

  $authStore: ReturnType<typeof useAuthStore>;

  interval: ReturnType<typeof setInterval> | null;

  /**
   * Constructor
   *
   * @param quasar - Quasar instance
   * @param errorService - error service instance
   * @param routerService - router service instance
   */
  constructor(
    quasar: QVueGlobals,
    errorService: ErrorService,
    routerService: RouterService
  ) {
    // Store
    this.$authStore = useAuthStore();

    // Set up authentication user pool
    const poolSettings = {
      UserPoolId: Env.VUE_APP_USER_POOL_ID,
      ClientId: Env.VUE_APP_USER_POOL_CLIENT_ID,
    };
    const userPool = new AmazonCognitoIdentity.CognitoUserPool(poolSettings);
    this.$authStore.setUserPool(userPool);

    // Quasar & environment variables
    this.$q = quasar;
    this.appName = Env.VUE_APP_NAME;

    // Error service
    this.$errorService = errorService;

    // Router service
    this.$routerService = routerService;

    // Every 5 minutes, check whether token is still valid
    if (!Env.SERVER) {
      this.interval = setInterval(() => {
        void this.refreshToken();
      }, 1000 * 60 * 5);
    } else {
      this.interval = null;
    }
  }

  /**
   * Logs into the AWS authentication pool using the given data
   *
   * @param identifier - the authentication's identifier (usually E-mail or username)
   * @param password - the authentication's password
   * @param q - Quasar instance (for opening dialogs)
   * @param [newPassword] - the new password if this function is triggered from set-password page
   */
  async login(
    identifier: string,
    password: string,
    q: QVueGlobals,
    newPassword = ''
  ): Promise<void> {
    // Generate auth details
    const authenticationDetails =
      new AmazonCognitoIdentity.AuthenticationDetails({
        Username: identifier,
        Password: password,
      });

    const { userPool } = this.$authStore;

    if (!userPool) {
      this.$errorService.showErrorDialog(new Error(userNotDefinedError));
      return undefined;
    }
    // Actual Cognito authentication on given pool
    const cognitoUser = new AmazonCognitoIdentity.CognitoUser({
      Username: identifier,
      Pool: userPool,
    });

    // Execute auth function
    return new Promise((resolve) => {
      // Store in local variable
      this.$authStore.setCognitoUser(cognitoUser);
      cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: (result) => {
          void this.loginSuccess(result);
          resolve();
        },
        onFailure: (err: Error) => {
          this.onFailure(err, identifier, password);
        },
        // Sets up MFA & logs in (only done once after signing up)
        mfaSetup: () => {
          this.setupMFA(cognitoUser, resolve, identifier);
        },

        // Called in order to select the MFA token type (SOFTWARE_TOKEN_MFA or SMS_TOKEN_MFA)
        selectMFAType() {
          cognitoUser.sendMFASelectionAnswer('SOFTWARE_TOKEN_MFA', this);
        },

        // Called when user is in FORCE_PASSWORD_CHANGE state and must thus set a new password
        newPasswordRequired(
          userAttributes: Record<string, unknown>, // all user attributes (e.g. 'email', 'email_verified')
          requiredAttributes: string[] // list of attributes that must be passed in order to update password (usually none)
        ) {
          // Build list of required attributes (for most user pools, this will be left empty)
          const attributes: Record<string, unknown> = {};
          if (requiredAttributes?.length > 0) {
            Object.keys(userAttributes).forEach((key) => {
              if (requiredAttributes.includes(key)) {
                attributes[key] = userAttributes[key];
              }
            });
          }

          // Case 1: Password already given; complete directly
          if (newPassword) {
            cognitoUser.completeNewPasswordChallenge(
              newPassword,
              attributes,
              this
            );
            return;
          }

          // Case 2: No password given: show dialog for setting new one
          q.dialog({
            component: ChangePasswordDialog,
          }).onOk(({ passwordNew }: { passwordNew: string }) => {
            cognitoUser.completeNewPasswordChallenge(
              passwordNew,
              attributes,
              this
            );
          });
        },

        // Called if time-limited one time password is required (only second login or later)
        totpRequired: (tokenType: string) => {
          this.verify2FACode(tokenType, resolve);
        },

        // MFA code required (NOT part of normal flow)
        mfaRequired() {
          // eslint-disable-next-line no-alert
          const verificationCode = prompt(
            i18n.global.t('messages.enter_verification_code', '')
          );
          if (typeof verificationCode === 'string') {
            cognitoUser.sendMFACode(verificationCode, this);
          }
        },
      });
    });
  }

  /**
   * Sets up MFA for the given cognito user
   *
   * @param cognitoUser - the user
   * @param resolve - resolve function
   * @param identifier - identifier (username of email)
   */
  setupMFA(
    cognitoUser: CognitoUser,
    resolve: (value: void | PromiseLike<void>) => void,
    identifier: string
  ): void {
    cognitoUser.associateSoftwareToken({
      // eslint-disable-next-line @typescript-eslint/no-misused-promises
      associateSecretCode: async (secret: string) => {
        this.$authStore.setCognitoUser(cognitoUser);
        await this.showQRCodeDialog(secret, cognitoUser, identifier);
        resolve();
      },
      onFailure: (err: Error) => {
        this.onFailure(err);
      },
    });
  }

  /**
   * Lets an admin create a new user
   * @param username - the chosen username
   * @param email - the authentication's e-mail address
   * @param role - the user's role
   * @param deliveryMediums - medium to use to deliver user's new login information (sms, email, both or none)
   * @param [phoneNumber] - number to send the SMS invitation to
   * @param [locale] - the chosen language locale
   * @returns - the newly created user
   */
  // eslint-disable-next-line class-methods-use-this
  async adminCreateUser(
    username: string,
    email: string,
    role: ROLE,
    deliveryMediums: DELIVERY_MEDIUMS[],
    phoneNumber?: string,
    locale?: string
  ): Promise<AdminCreatedUser | null> {
    return adminCreateUser(
      username,
      email,
      role,
      deliveryMediums,
      phoneNumber,
      locale
    );
  }

  /**
   * Signs up by creating a new authentication using the given Username, e-mail, password and language.
   *
   * @param username - the chosen username
   * @param email - the authentication's e-mail address
   * @param password - the new authentication's chosen password. Must fulfill the set password conditions
   * @param [lang] - the chosen language
   */
  // eslint-disable-next-line class-methods-use-this
  async signup(
    username: string,
    email: string,
    password: string,
    lang?: string
  ): Promise<UserEntity | null> {
    return signup(username, email, password, lang);
  }

  /**
   * Disable all users in the given list
   *
   * @param users - list of users to disable
   */
  // eslint-disable-next-line class-methods-use-this
  async disableUsers(
    users: UserEntity[]
  ): Promise<PromiseSettledResult<Awaited<UserEntity> | null>[]> {
    const disableRequests = users.map((user: UserEntity) =>
      disableUser(user.uuid)
    );
    return Promise.allSettled(disableRequests);
  }

  /**
   * Enable all users in the given list
   *
   * @param users - list of users to enable
   */
  // eslint-disable-next-line class-methods-use-this
  async enableUsers(
    users: UserEntity[]
  ): Promise<PromiseSettledResult<Awaited<UserEntity> | null>[]> {
    const disableRequests = users.map((user: UserEntity) =>
      enableUser(user.uuid)
    );
    return Promise.allSettled(disableRequests);
  }

  /**
   * Logs out the currently logged in authentication (if any)
   */
  async logout(): Promise<void> {
    // Deep copy to avoid mutating store state
    const cognitoUser: CognitoUser | undefined = cloneDeep(
      this.$authStore.cognitoUser
    );

    if (!cognitoUser) {
      this.$errorService.showErrorDialog(
        new Error(i18n.global.t('errors.not_logged_in'))
      );
    } else {
      // Sign out
      await new Promise<void>((resolve) => {
        cognitoUser.signOut(() => {
          this.$authStore.setCognitoUser(undefined);
          this.$authStore.setUserSession(undefined);
          localStorage.clear(); // Needed to remove session,id,... tokens
          resolve();
        });
      });

      // Redirect to login
      await this.$routerService.routeTo(ROUTES.LOGIN);

      // Clear cache to prevent erroneous loading when changing roles
      const apolloClient = useApolloClient();
      await apolloClient.client.clearStore();
    }
  }

  /**
   * Shows a dialog for changing password
   */
  showChangePasswordDialog(): void {
    this.$q
      .dialog({
        component: ChangePasswordDialog,
        componentProps: {},
      })
      .onOk(
        ({
          passwordNew,
          passwordOld,
        }: {
          passwordNew: string;
          passwordOld: string;
        }) => {
          this.$authStore.cognitoUser?.changePassword(
            passwordOld,
            passwordNew,
            (err: Error | undefined) => {
              if (err) {
                this.$errorService.showErrorDialog(err);
              }
            }
          );
        }
      );
  }

  /**
   * Shows a dialog for requesting password reset
   */
  showResetPasswordDialog(): void {
    const { userPool } = this.$authStore;

    if (userPool === undefined) {
      this.$errorService.showErrorDialog(new Error(userNotDefinedError));
      return;
    }

    this.$q
      .dialog({
        component: ForgotPasswordDialog,
      })
      .onOk((username: string) => {
        // Set up cognitoUser first
        this.$authStore.setCognitoUser(
          new CognitoUser({
            Username: username,
            Pool: userPool,
          })
        );

        // Call forgotPassword on cognitoUser
        this.$authStore.cognitoUser?.forgotPassword({
          onSuccess() {
            // Do nothing
          },
          onFailure: (err: Error) => {
            this.$authStore.setCognitoUser(undefined);
            this.onFailure(err);
          },
          inputVerificationCode: () => {
            this.showResetPasswordFormDialog();
          },
        });
      });
  }

  /**
   * Show actual password reset form dialog
   */
  showResetPasswordFormDialog(): void {
    this.$q
      .dialog({
        component: ResetPasswordDialog,
      })
      .onOk(
        ({
          verificationCode,
          newPassword,
        }: {
          verificationCode: string;
          newPassword: string;
        }) => {
          this.$authStore.cognitoUser?.confirmPassword(
            verificationCode,
            newPassword,
            {
              onSuccess: () => {
                showSuccessNotification(
                  this.$q,
                  i18n.global.t('messages.password_changed')
                );
              },
              onFailure: (err: Error) => {
                this.$errorService.showErrorDialog(err);
              },
            }
          );
        }
      );
  }

  /**
   * Shows a dialog for requesting password reset. The verification code is sent to the given e-mail address
   * @param username - the username of the logged-in user
   * @returns void
   */
  showRequestNewPasswordDialog(username: string): void {
    const { userPool } = this.$authStore;

    if (userPool === undefined) {
      this.$errorService.showErrorDialog(new Error(userNotDefinedError));
      return;
    }

    this.$authStore.setCognitoUser(
      new CognitoUser({
        Username: username,
        Pool: userPool,
      })
    );

    // Call forgotPassword on cognitoUser
    this.$authStore.cognitoUser?.forgotPassword({
      onSuccess() {
        // Do nothing
      },
      onFailure: (err: Error) => {
        this.$authStore.setCognitoUser(undefined);
        this.onFailure(err);
      },
      inputVerificationCode: () => {
        this.showResetPasswordFormDialog();
      },
    });
  }

  /**
   * Resends the e-mail configuration code for the current user
   */
  async resendEmailVerificationCode(): Promise<void> {
    await new Promise((resolve, reject) => {
      this.$authStore.cognitoUser?.resendConfirmationCode((err) => {
        if (!err) {
          resolve(null);
        }
        reject();
      });
    });
  }

  /**
   * Shows a dialog for verifying E-Mail
   */
  async showEmailVerificationDialog(): Promise<void> {
    await new Promise((resolve, reject) => {
      this.$q
        .dialog({
          component: EmailConfirmationDialog,
          componentProps: {
            q: this.$q,
            authService: this,
          },
        })
        .onOk(({ code }: { code: string }) => {
          this.$authStore.cognitoUser?.confirmRegistration(
            code,
            true,
            (err, result) => {
              if (!err) {
                resolve(result);
              }
              reject();
            }
          );
        });
    });
  }

  /**
   * Shows a dialog containing a QR code for setting up two factor authentication
   *
   * @param secretCode - the authenticator code to encode in QR code form
   * @param cognitoUser - the cognito user to show the dialog for
   * @param identifier - identifier (username of email)
   * @returns void
   */
  showQRCodeDialog(
    secretCode: string,
    cognitoUser: CognitoUser,
    identifier: string
  ): Promise<void> {
    return new Promise((resolve) => {
      const codeUrl = `otpauth://totp/${this.appName} (${identifier})?secret=${secretCode}&Issuer=${this.appName}`;
      this.$q
        .dialog({
          component: QrCodeDialog,
          componentProps: {
            value: codeUrl,
          },
        })
        .onOk(() => {
          // Verify code
          this.$q
            .dialog({
              component: MFADialog,
            })
            .onOk((code: string) => {
              cognitoUser.verifySoftwareToken(code, 'TOTP Device', {
                onSuccess: (userSession: CognitoUserSession) => {
                  void this.loginSuccess(userSession);
                  resolve();
                },
                onFailure: (error: Error) => {
                  this.onFailure(error);
                },
              });
            });
        });
    });
  }

  /**
   * Confirm e-mail verification code
   *
   * @param code -verification code
   */
  async verifyEmail(code: string): Promise<void> {
    return new Promise((resolve, reject) => {
      this.$authStore.cognitoUser?.confirmRegistration(
        code,
        true,
        (err: Error) => {
          if (err) {
            console.error(err);
            reject();
          }
          resolve();
        }
      );
    });
  }

  /**
   * Verifies a given 2FA code
   *
   * @param tokenType - the type of token to verify
   * @param resolve - resolve function
   */
  verify2FACode(
    tokenType: string,
    resolve: (value: void | PromiseLike<void>) => void
  ): void {
    // Verify code
    this.$q
      .dialog({
        component: MFADialog,
      })
      .onOk((code: string) => {
        // Deep copy user so state object does not get altered
        const currentUser: CognitoUser | undefined = cloneDeep(
          this.$authStore.cognitoUser
        );
        currentUser?.sendMFACode(
          code,
          {
            onSuccess: (userSession: CognitoUserSession) => {
              this.$authStore.setCognitoUser(currentUser);
              void this.loginSuccess(userSession);
              resolve();
            },
            onFailure: (error: Error) => {
              this.onFailure(error);
            },
          },
          tokenType
        );
      });
  }

  /**
   * When login succeeds
   *
   * @param userSession - the currently active Cognito authentication session
   */
  async loginSuccess(userSession: CognitoUserSession): Promise<void> {
    // Store locally
    this.$authStore.setUserSession(userSession);

    // Redirect to main page TODO application specific: choose correct route
    await this.$routerService?.routeTo(ROUTES.HOME);
  }

  /**
   * When any operation (mostly login) fails, verify whether it is due to the authentication not having verified their account
   *
   * @param error - the error that caused the failure
   * @param identifier - the authentication's identifier (usually E-mail or username) for re-login
   * @param password - the authentication's password for re-login
   */
  onFailure(error: Error, identifier?: string, password?: string): void {
    let message;

    switch (error.name) {
      // Case 1: User has not verified their e-mail yet
      case 'UserNotConfirmedException':
        void this.showEmailVerificationDialog().then(() => {
          if (identifier && password) {
            // Retry login
            void this.login(identifier, password, this.$q);
          }
        });
        break;
      // Case 2: User must reset password (e.g. if forced via AWS console)
      case 'PasswordResetRequiredException':
        // Call forgotPassword on cognitoUser, since user must reset password
        this.$authStore.cognitoUser?.forgotPassword({
          onSuccess() {
            const $q = useQuasar();

            // Show success notification
            showSuccessNotification(
              $q,
              i18n.global.t('messages.password_changed')
            );
          },
          onFailure: (err: Error) => {
            this.$authStore.setCognitoUser(undefined);
            this.onFailure(err);
          },
          inputVerificationCode: () => {
            this.showResetPasswordFormDialog();
          },
        });
        break;
      // Case 3: Wrong password or user's Cognito account is disabled
      case 'NotAuthorizedException':
        if (error.message.includes('Incorrect username or password')) {
          // Wrong username / password
          message = i18n.global.t('errors.incorrect_username_or_password');
        } else if (error.message.includes('User is disabled')) {
          // Account disabled
          message = i18n.global.t('errors.account_disabled');
        } else if (
          error.message.includes(
            'User password cannot be reset in the current state'
          )
        ) {
          // Password can't be reset
          message = i18n.global.t('errors.cannot_reset_password');
        }

        // Other NotAuthorizedExceptions are handled directly
        this.$errorService.showErrorDialog(
          message ? new Error(message) : error
        );
        break;
      // Default: any other error
      default:
        this.$errorService.showErrorDialog(error);
        break;
    }
  }

  /**
   * Refreshes the idToken if necessary
   *
   * @returns void
   */
  refreshToken(): Promise<void> {
    return new Promise((resolve, reject) => {
      const { userSession } = this.$authStore;
      const idTokenExpiration = userSession?.getIdToken().getExpiration();
      if (!idTokenExpiration) {
        resolve();
      }
      const refreshToken = userSession?.getRefreshToken();
      if (
        refreshToken &&
        idTokenExpiration &&
        idTokenExpiration - Date.now() / 1000 < 45 * 60
      ) {
        // 15min before de-validation token is refreshed
        const currentUser: CognitoUser | undefined = cloneDeep(
          this.$authStore.cognitoUser
        ); // refresh session mutates the state of store: illegal
        currentUser?.refreshSession(refreshToken, (err, session) => {
          if (session) {
            this.$authStore.setCognitoUser(currentUser);
            this.$authStore.setUserSession(
              session as CognitoUserSession | undefined
            );
            resolve();
          } else {
            reject(err);
          }
        });
      } else {
        resolve();
      }
    });
  }
}

bd74d00ad8a0712dbb2e46e8eaac9c26946e6045