Source

store/auth/index.js

/**
 * @namespace Stores
 * @category Stores
 * @module auth-store
 * @todo Remove Vue global (Vue3 migration)
 * */
import { isTestMode } from '@/services/env-service.js'
import Vue from 'vue'
import api from '@/api'
import jwt_decode from 'jwt-decode'
import cache from '@/utils/cache'
import { getQueryStringValue } from '@/utils/querystring'
import moment from 'moment'
import logginserService from '@/services/logging-service.js'
import APIUrls from '@/config/simpliciti-apis.js'
import { encodeLoginNameClientId } from '@/services/auth-service.js'
import { saveSettingsParamLocally } from '@/services/settings-service.js'
import storage from '@/plugins/vue-local-storage.js'
const shouldLog =
  (getQueryStringValue('verbose') || '').includes('1') ||
  (getQueryStringValue('verbose') || '').includes('auth')
const apiStorage = storage.fromNamespace('api')

let scope = (window.__authScope = {
  syncInProgress: false,
})

/**
 * @todo Normalize attribute names (underscore -> snake case)
 * @returns {Object} new state
 */
function newState() {
  return {
    user_logged: false,
    user_infos: {
      login: '',
      client_nom: '',
      client_id: '',
      /**
       * main jwt (tokens containing "to_client" can't be here)
       */
      token: '', //Will be used by api wrapper if toClientToken is empty
      updated_at: '',
      userId: '',
      /**
       * child token (tokens containing "to_client" token must be here)
       */
      toClientToken: '', //Will be used by api wrapper by default (fallbacks to token property)
      toClientId: '',
      hasChildClients: false,
      country: '', //client country code (i.g FR)
    },
    user_rights: [],
    externalRights: {
      admin: false,
      analyse: false,
      citifret: false,
      citipav: false,
      editour: false, //unused
      simpliboard: false, //unused
    },
    logoutRedirect: false, //Deprecated
    logoutRedirectUrl: process.env.VUE_APP_GEORED_HOST, //Deprecated
  }
}

/**
 * @namespace Stores
 * @category Stores
 * @module auth-store
 *
 * @todo Improve cache: Save the entire store state rather than a subset
 * @todo Normalize properties once (after API fetch)
 * @todo Refactor/Move auth logic to service
 *
 * 09-02-23 Readonly rights in production
 * */
export default {
  namespaced: true,
  state: newState(),
  getters: {
    /**
     * Will return login/client from a login-as token
     * @param {*} state
     * @returns {Object} {login:"",client:""} i.g {login:"jarancibia",client:"DGD"}
     */
    loginAs(state, getters) {
      let decoded = {}
      try {
        decoded = jwt_decode(state.user_infos.toClientToken)
      } catch (err) {
        decoded = {}
      }
      let toClient = decoded.to_client || ''
      return {
        enabled: !!toClient,
        login: state.user_infos.login,
        client: toClient || getters.clientName,
      }
    },
    isLoginAs(state, getters) {
      return getters['loginAs'].enabled
    },
    jwt(state) {
      return state.user_infos.toClientToken || state.user_infos.token || ''
    },
    parentJwt(state) {
      return state.user_infos.token || ''
    },
    loginNameClientIdEncoded(state, getters) {
      return getters['isLogged']
        ? encodeLoginNameClientId(getters['loginName'], getters['clientId'])
        : ''
    },
    isSabadmin(state) {
      return state.user_infos.login === 'sabadmin'
    },
    loginName(state) {
      return state.user_infos.login
    },
    clientId(state) {
      return state.user_infos.client_id
    },
    userId(state) {
      return state.user_infos.userId
    },
    toClientId(state) {
      return state.user_infos.toClientId
    },
    clientName(state) {
      return state.user_infos.client_nom
    },
    clientCountry: (state) => (state.user_infos.country || 'fr').toLowerCase(),
    isLogged(state) {
      return state.user_logged
    },
    rightsList(state) {
      return state.user_rights
    },
    hasChildClients: (state) => state.user_infos.hasChildClients,
    hasRight: (state) => (rightCode) => state.user_rights.includes(rightCode),
    hasExternalRight: (state) => (name) =>
      (state.externalRights[name] || false) === true,
    /**
     * @see LoginAsFeature.vue
     * @returns {Boolean}
     */
    canUseClientSelectionFeature(state, getters) {
      //Use case: V2 (login as child client) --> V3 (Parent token is not passed by, so we are not able to switch client again) (Not implemented)
      if (!state.user_infos.token) {
        return false
      }
      //Has child clients
      return (
        getters['hasChildClients'] ||
        //Or has "Access to login client"
        Vue.$rights.hasFeatureRight('common_login_as_access') ||
        //Or has "Connect as" and is not sabatier
        (getters['clientName'] !== 'sabatier' &&
          Vue.$rights.hasFeatureRight('common_login_as_user'))
      )
    },
  },
  mutations: {
    setLogged(state, infos = {}) {
      infos = infos === null ? {} : infos
      if (Object.keys(infos).length > 0) {
        state.user_infos = Object.assign(
          {},
          {
            login: infos.user || infos.login || '',
            client_id: infos.client_id || infos.clientId || '',
            client_nom: infos.client_nom || infos.client || '',
            token: infos.token,
            updated_at: infos.updated_at || Date.now(),
            userId: infos.userId,
            toClientToken: infos.toClientToken,
            toClientId: infos.toClientId,
            hasChildClients:
              infos.hasChildClients !== undefined
                ? infos.hasChildClients
                : false,
            country: infos.country || '',
          }
        )

        state.user_rights = isTestMode()
          ? infos.rights || []
          : Object.freeze(infos.rights || [])

        state.externalRights = infos.externalRights
        logginserService.identifyUser({
          id: infos.userId,
        })
        logginserService.setContext('user_details', {
          client_id: state.user_infos.client_id,
          client_name: state.user_infos.client_nom,
        })
      } else {
        logginserService.identifyUser(null)
        logginserService.setContext('user_details', {})
      }
      state.user_logged =
        !!state.user_infos.toClientToken || !!state.user_infos.token
    },
    /**
     * @deprecated
     */
    addUserRights(state, rights = []) {
      state.user_rights = state.user_rights.concat(rights)
    },
    /**
     * @deprecated
     */
    setLogoutRedirect(state, logoutRedirect) {
      state.logoutRedirect = logoutRedirect
    },
    removeCredentials(state) {
      Object.assign(state, newState())
    },
  },
  actions: {
    /**
     * @function authenticateUser
     * @returns {Boolean}
     * @private
     */
    async authenticateUser({ commit, dispatch, rootGetters }, userInfos) {
      shouldLog && console.log('auth::authenticateUser')
      await cache.setItem('user_infos', userInfos)
      let currentLanguage = rootGetters['settings/getParameter'](
        'applicationLanguage'
      )
      if (currentLanguage) {
        //Persist the selected language in the login view (Priorized over client country language)
        //This should be done before setLogged mutation, otherwise components will try to syncCache right away.
        await saveSettingsParamLocally('applicationLanguage', currentLanguage, {
          suffix: encodeLoginNameClientId(
            userInfos.user || userInfos.login,
            userInfos.clientId || userInfos.client_id
          ),
        })
      }
      ;(async () => {
        //Update settings from cache, then from server
        await dispatch(
          'settings/syncFromCache',
          {},
          {
            root: true,
          }
        )
        await dispatch('settings/syncFromServer', '', {
          root: true,
        })
      })()

      commit('setLogged', userInfos)
      Vue.$loggingService.createSession({
        jwt: userInfos.toClientToken || userInfos.token,
      })

      return true
    },
    /**
     * Will perform a login-as operation without full-refresh (session change)
     * @param {*} param0
     * @param {*} token
     * @returns
     */
    async loginAs({ dispatch, commit, state }, { token, clientId }) {
      let userInfos = {
        ...state.user_infos,
        toClientToken: token,
        toClientId: clientId,
      }

      return await dispatch('syncLoggedUsingExistingInfos', {
        userInfos,
      })
    },
    async removeCredentials({ commit }) {
      shouldLog && console.log('syncLogged::removing-credentials')
      commit('removeCredentials')
      await cache.setItem('user_infos', null)
      await cache.removeItem('user_infos_parent_token')
    },
    /**
     * @function logout
     */
    async logout({ dispatch }) {
      shouldLog && console.log('auth::logout')
      await dispatch('removeCredentials')

      await dispatch('ecoConduite/resetState', '', {
        root: true,
      })
      await dispatch('search_module/resetStore', '', {
        root: true,
      })
      await dispatch('location_module/resetStore', '', {
        root: true,
      })
      await dispatch('settings/resetStore', '', {
        root: true,
      })
      await dispatch('zones/resetStore', '', {
        root: true,
      })
      await apiStorage.clear()

      window._logoutAt = Date.now()
    },
    /**
     * Set a new session on the fly. It will retrieve rights from API.
     * @function syncLoggedUsingExistingInfos
     * @param {Object} payload.userInfos new session information
     * @returns
     */
    async syncLoggedUsingExistingInfos(
      { commit, dispatch },
      { userInfos, ...options }
    ) {
      //shouldLog && console.log('syncLogged::syncLoggedUsingExistingInfos::userInfos',userInfos)
      await cache.setItem('user_infos', userInfos) //To be able to fetch rigths
      let rightsResponse = await getUserRightsNew(
        userInfos.token || userInfos.toClientToken
      )
      userInfos.externalRights = rightsResponse.externalRights
      userInfos.rights = rightsResponse.rights

      await dispatch('authenticateUser', userInfos)

      //console.log('auth login-ok (preserve token)')
      return true
    },
    /**
     * Sync session from local cache
     * @warning Two function calls at the same time might trigger unexpected results if you do things like removing cached token
     * @function syncLogged
     * @returns
     */
    async syncLogged({ commit, dispatch }, payload = {}) {
      shouldLog &&
        console.log('syncLogged', {
          payload,
        })

      let userInfos = {}

      if (scope.syncInProgress) {
        return new Promise((resolve, reject) => {
          dispatch('syncLogged', {}).then(resolve).catch(reject)
        }, 500)
      }

      let tokenToAuth

      //Use case: Token provided by _token= (Only process once)
      if (payload.token) {
        scope.syncInProgress = true
        try {
          tokenToAuth = payload.token
          let decoded = jwt_decode(payload.token)

          if (decoded.to_client) {
            //Use case: V2 (login as child client) --> V3 : Use will not be able to select a client again (Not implemented)
            shouldLog &&
              console.log('syncLogged::_token::login-as', decoded.to_client)
            userInfos.toClientToken = payload.token
          } else {
            shouldLog &&
              console.log('syncLogged::_token::simple', decoded.to_client)
            userInfos.token = payload.token //i,g V2 --> V3
          }
          await cache.setItem('user_infos', userInfos) //Write to cache ASAP because they might be multipe syncLogged calls and API requests that require JWT
          scope.syncInProgress = false
        } catch (err) {
          scope.syncInProgress = false
          throw err
        }
      } else {
        userInfos = (await cache.getItem('user_infos')) || {}
        tokenToAuth = userInfos.token
      }

      shouldLog && console.log('syncLogged::tokenToAuth', tokenToAuth)

      if (tokenToAuth) {
        try {
          let decoded = jwt_decode(tokenToAuth)

          //Keep using an existing token without server check (expiration check is done in the client) (++performance) (api check takes ~5s)
          if (
            moment().isBefore(moment(new Date(decoded.exp * 1000)), 'minute') &&
            Object.keys(userInfos).length > 1
          ) {
            shouldLog && console.log('syncLogged::token-still-valid')

            return dispatch('syncLoggedUsingExistingInfos', { userInfos })
          }

          let check_token = await api.post('/public/check_token', {
            token: tokenToAuth,
          })
          /*shouldLog &&
            console.log('syncLogged.', {
              check_token: check_token.data,
            })*/
          if (check_token.data.userId) {
            shouldLog && console.log('auth::success::check-token')
            let infos = await buildUserInfos(
              { data: userInfos }, // token will be passed by here: i.g {token:xxx} or {toClientToken:xxx}
              //{ data: tokenToAuth },
              null,
              check_token
            )

            if (infos.rights.length === 0) {
              dispatch(
                'alert/addAlert',
                {
                  title: '403',
                  text: 'alerts.NOT_ENOUGH_RIGHTS',
                  type: 'warning',
                },
                {
                  root: true,
                }
              )
              throw new Error('AUTH_NO_RIGHTS')
            }

            await dispatch('authenticateUser', infos)

            shouldLog && console.log('auth login-ok (token regen)')

            return true
          } else {
            shouldLog && console.log('auth::fail::check-token')
          }
        } catch (err) {
          console.warn(err)
          Vue.ErrorLogger.logError(err, {
            store: 'auth',
            action: 'syncLogged',
          })
        }
      }

      return false
    },
    /**
     * Start a new session with login/password
     * @function login
     * @returns
     */
    async login({ commit, dispatch, getters }, { login, client, password }) {
      //await dispatch("syncLogged");

      if (getters.isLogged) {
        return
      }

      let account_infos = await api.postFormUrlEncoded(
        '/public/authentification',
        {
          login,
          client,
          password,
        }
      )
      account_infos.data =
        account_infos.data.length !== undefined
          ? account_infos.data[0]
          : account_infos.data

      let isLoginSuccess =
        account_infos && account_infos.data && !!account_infos.data.token

      //shouldLog && console.log({ account_infos: account_infos.data })

      if (!isLoginSuccess) {
        dispatch(
          'alert/addAlert',
          {
            title: '401',
            text: 'alerts.LOGIN_FAIL',
            type: 'warning',
          },
          {
            root: true,
          }
        )
        throw new Error('AUTH_LOGIN_INFOS_FAIL')
      }

      let token_info = await api.postFormUrlEncoded('/public/token', {
        login,
        client,
        password,
      })
      token_info.data =
        token_info.data.length !== undefined
          ? token_info.data[0]
          : token_info.data

      //shouldLog && console.log({ token_info })

      let check_token = await api.post('/public/check_token', {
        token: token_info.data,
      })

      //shouldLog && console.log({ check_token })

      if (!(check_token.data && check_token.data.userId)) {
        throw new Error('AUTH_LOGIN_TOKEN_CHECK_FAIL')
      }

      let infos = await buildUserInfos(account_infos, token_info, check_token)
      //dispatch("syncExternalUserRights", token_info.data);

      if (infos.rights.length === 0) {
        dispatch(
          'alert/addAlert',
          {
            title: '403',
            text: 'alerts.NOT_ENOUGH_RIGHTS',
            type: 'warning',
          },
          {
            root: true,
          }
        )
        throw new Error('AUTH_NO_RIGHTS')
      }

      await dispatch('authenticateUser', infos)

      shouldLog && console.log('auth login-ok')

      return {
        shouldRouteToClientSelection: getters['canUseClientSelectionFeature'],
      }
    },
  },
}

/**
 *
 * @param {String} jwt
 * @returns
 */
async function getUserRightsNew(jwt) {
  shouldLog && console.log('auth::getUserRightsNew::jwt', jwt)
  let rightsResponse = (
    await api.v3.get(
      `${APIUrls.APIV3_USER_ACCESS_RIGHTS}?typeSectionId=12`,
      {},
      {
        jwt,
      }
    )
  ).data
  let rights = rightsResponse.rights.map((item) => item.code)
  if (rightsResponse.commonRights) {
    rights = rights.concat(rightsResponse.commonRights.map((r) => r.code))
  }
  return {
    rights,
    externalRights: rightsResponse.externalRights,
  }
}

/**
 * Those informations are stored locally (client-cache)
 * 21-01-22: API check_token "token" should override existing token (To handle rights when redirecting applications, for example: V3 -> Citifret)
 * @todo Refactor or replace by syncLoggedUsingExistingInfos
 */
async function buildUserInfos(
  userInfos = {},
  tokenInfos = {},
  checkTokenInfos = {}
) {
  let token =
    (checkTokenInfos.data || {}).token ||
    tokenInfos?.data ||
    (userInfos?.data || {}).token

  shouldLog &&
    console.log('auth::buildUserInfos::token', {
      token,
      userInfos,
      tokenInfos,
      checkTokenInfos,
    })

  let anyJWT = token || (userInfos?.data || {}).toClientToken

  //New grab rights from new api
  let rightsResponse = await getUserRightsNew(
    token || (userInfos?.data || {}).toClientToken
  )

  shouldLog &&
    console.log('buildUserInfos', {
      userInfos,
      tokenInfos,
      checkTokenInfos,
    })

  return {
    rights: rightsResponse.rights,
    externalRights: rightsResponse.externalRights,
    ...userInfos.data,
    ...(checkTokenInfos.data || {}),
    token, //parent token only (token should not contain to_client attribute)
    toClientToken: userInfos?.data?.toClientToken || '', //child token (login-as feature)
    toClientId: userInfos?.data?.toClientId || '',
    hasChildClients: userInfos?.data?.childs,
    password: '#####',
    updated_at: Date.now(),
    country:
      userInfos?.data?.country ||
      (await Vue.$auth.getClientParameter('Pays', {
        jwt: anyJWT,
      })),
  }
}