Source: component/controllerServer.js

/**
 * This file is part of Sesatheque.
 *   Copyright 2014-2015, Association Sésamath
 *
 * Sesatheque is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License version 3
 * as published by the Free Software Foundation.
 *
 * Sesatheque is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Sesatheque (LICENCE.txt).
 * @see http://www.gnu.org/licenses/agpl.txt
 *
 *
 * Ce fichier fait partie de l'application Sésathèque, créée par l'association Sésamath.
 *
 * Sésathèque est un logiciel libre ; vous pouvez le redistribuer ou le modifier suivant
 * les termes de la GNU Affero General Public License version 3 telle que publiée par la
 * Free Software Foundation.
 * Sésathèque est distribué dans l'espoir qu'il sera utile, mais SANS AUCUNE GARANTIE,
 * sans même la garantie tacite de QUALITÉ MARCHANDE ou d'ADÉQUATION à UN BUT PARTICULIER.
 * Consultez la GNU Affero General Public License pour plus de détails.
 * Vous devez avoir reçu une copie de la GNU General Public License en même temps que Sésathèque
 * (cf LICENCE.txt et http://vvlibri.org/fr/Analyse/gnu-affero-general-public-license-v3-analyse
 * pour une explication en français)
 */
'use strict'
const path = require('path')
const flow = require('an-flow')
const { hasProp } = require('sesajstools')
const log = require('sesajstools/utils/log')

/**
 * @private
 * @type {$json}
 */
const $json = require('./json')
const { getAuthBundleKey, getUserKey, xFerPrefix } = require('./common')
const { errorCallback } = require('./helper')

const instanceId = process.env.NODE_APP_INSTANCE || 0
const ssoLog = (str) => log(`sesalabSso[${instanceId}], ${str}`)

// pour conserver le authToken entre validate et afterlogin
const authTokenTtl = 60

// pour le redirect, on a 50% de chance de se planter,
// 3 retries (donc 4 essais) => on tombe à 0.5^4 soit 6% d'échec
// 10 retries => 1‰
// pas la peine de mettre plus car le navigateur suivra plus
const maxRetries = 9

module.exports = function (component, prefix) {
  /**
   * Controleur pour gérer les routes du module sesalab-sso (SSO entre sesalab et sesatheque)
   * @controller controllerSesalabSsoServer
   */
  component.controller(prefix, function ($cache, $sesalabSsoServer, $sesalabSso) {
    // si on revient sur une autre instance du cluster node que celle qui a fait le $cache.set,
    // $cache.get ne remonte pas la valeur, même en refaisant des essais pendant 3s,
    // cf la fct cacheGet du commit bc4138f

    // On passe donc au reload via un redirect, c'est assez rapide avec 5~15ms par essai

    /**
     * Rappelle next avec la valeur de la clé ou une erreur si on a déjà rééssayé plus de maxRetries fois,
     * sinon fait un redirect vers la même url en incrémentant le nb de retries
     * @param context
     * @param key
     * @param next
     */
    function cacheGetOrRetry (context, key, next) {
      const retries = (context.get.retries && Number(context.get.retries)) || 0
      flow().seq(function () {
        $cache.get(key, this)
      }).seq(function (data) {
        const result = data ? 'OK' : 'KO'
        const msg = `cacheGetOrRetry ${result} pour ${key} (essai n° ${retries + 1})`
        ssoLog(msg)
        if (data) return next(null, data)
        // pas trouvé
        log.error(msg)
        if (retries >= maxRetries) return this('La propagation de l’authentification a échouée')
        // en tente via un rechargement de la page, pour espérer tomber sur la bonne instance du cluster node
        let url = context.request.url
        if (retries === 0) url += '&retries=1' // on a forcément déjà une queryString
        else url = url.replace(/retries=[0-9]+/, `retries=${retries + 1}`)
        // le taux d'échec suit pas du tout une gaussienne, on est à 6% qui vont au 10e essai HS
        // on delay un peu si ça veut pas
        if (retries < maxRetries / 2) context.redirect(url)
        else setTimeout(() => context.redirect(url), retries * 50)
      }).catch(next)
    }

    // le statique, ici sur la racine même si le contrôleur est déclaré sur /sesalabSso/, pour /logout.bundle.js
    this.serve('/', { maxAge: '7d', fsPath: path.join(__dirname, '..', 'dist') })

    /**
     * Au retour d'un login chez un client
     * @route GET /sesalabSso/afterlogin
     */
    this.get('afterlogin', function (context) {
      // console.log('cookies sur /sesalabSso/afterlogin', context.request.cookies)
      // console.log('session sur /sesalabSso/afterlogin', context.session)
      // coté sésalab user.oid dans context.session.utilisateur.oid
      const ssoToken = context.get.token
      const errorCb = (msg) => errorCallback(context, msg)
      let cacheKey

      flow().seq(function () {
        if (context.get.error) return errorCb(context.get.error)
        if (!ssoToken) return errorCb('token SSO absent')
        // on récupère le authBundle que le validate avais mis en cache
        cacheKey = getAuthBundleKey(ssoToken)
        cacheGetOrRetry(context, cacheKey, this)
      }).seq(function (authBundle) {
        if (!authBundle) return errorCb('La propagation de l’authentification a échouée')
        // console.log(`afterLogin ${ssoToken} OK`, context.session.authTokensByOrigin)
        ssoLog(`afterLogin ${ssoToken} OK (sur ${authBundle.baseUrl}, clé ${cacheKey})`)
        // met en session un authToken pour appeler le client
        $sesalabSso.setAuthToken(context, authBundle)
        if (!context.session || !context.session.sesalabSso) return errorCb('Pas de session récupérée, impossible de valider l’authentification')
        if (!hasProp(context.session.sesalabSso, 'clientIndex')) return errorCb('rappel après login sans demande de login préalable en session')
        // c'est bon => login
        $sesalabSsoServer.loginOnClients(context)
      }).catch(function (error) {
        console.error(error)
        errorCb(error.message)
      })
    })

    /**
     * Répond à une demande de validation d'un token (appel direct du nodejs d'un site client, donc pas de session)
     * On récupère le authToken qui permettra de le rappeler dans le header authorization
     * @route GET /sesalabSso/validate
     */
    this.get('validate', function (context) {
      let ssoToken
      let userKey
      let origin
      let authToken
      let user
      flow().seq(function () {
        // le token qu'on a créé et envoyé lors de la redirection vers le login, associé à un user
        ssoToken = context.get.token
        if (!ssoToken) throw new Error('token SSO absent')
        userKey = getUserKey(ssoToken)
        origin = context.request.headers.origin
        // c'est obligatoire
        if (!origin) throw new Error('Header Origin manquant')
        if (origin.substr(-1) !== '/') origin += '/'
        // on veut aussi le authToken que appClient nous passe en header (pour qu'on l'utilise avec lui)
        if (!context.request.headers.authorization) throw new Error('authToken reçu au validate invalide (header authorization)')
        const chunks = context.request.headers.authorization.split(' ')
        authToken = chunks[0] === xFerPrefix && chunks[1]
        if (!authToken) throw new Error('pas de authToken au validate (header authorization), ou Origin manquant')
        cacheGetOrRetry(context, userKey, this)
      }).seq(function (userFound) {
        const next = this
        if (!userFound) {
          ssoLog(`pas de user en cache au validate pour la clé ${userKey}`)
          return next(`La propagation de l’authentification a échouée, les prochaines requêtes vers ${origin} ne seront pas authentifiées (certaines se verront alors refusées)`)
        }
        user = userFound
        // console.log('user associé au token', user)
        // on met en cache la clé d'authentification de ce user sur ce client qui nous appelle
        // (ici on a pas la session du user puisqu'on est appellé par le serveur node de l'appli cliente SSO)
        const authBundle = { baseUrl: origin, token: authToken }
        const cacheKey = getAuthBundleKey(ssoToken)
        ssoLog(`validate user OK, met en cache un bundle pour ${ssoToken} (clé ${cacheKey}, user ${userFound.nom})`)
        $cache.set(cacheKey, authBundle, authTokenTtl, function (error) {
          if (error) return next(error)
          ssoLog(`mise en cache du bundle OK (clé ${cacheKey}, user ${userFound.nom})`)
          next()
        })
      }).seq(function () {
        // et on renvoie le user
        $json.sendOk(context, { user })
      }).catch(function (error) {
        console.error(error)
        $json.sendError(context, error)
      })
    })
  })
}