Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

application specific: choose correct route #466

Open
github-actions bot opened this issue Apr 20, 2023 · 0 comments
Open

application specific: choose correct route #466

github-actions bot opened this issue Apr 20, 2023 · 0 comments
Labels

Comments

@github-actions
Copy link

application specific: choose correct route

// Redirect to main page TODO application specific: choose correct route

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

@github-actions github-actions bot added the todo label Apr 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

0 participants