/**
* 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)
})
})
})
}