From 9349c23151c6c72bfe905fce219d2dedbeb14170 Mon Sep 17 00:00:00 2001
From: Patrick Barroca <pbarroca@afi-sa.fr>
Date: Tue, 11 Feb 2020 14:51:51 +0100
Subject: [PATCH] dev #79874 : identity providers with types

---
 .../scripts/identity-providers/index.phtml    |  22 +++-
 .../opac/controllers/AbonneController.php     |  15 ++-
 .../opac/controllers/AuthController.php       |  10 --
 .../IdentityProvidersController.php           |   5 +-
 .../scripts/abonne/associated-providers.phtml |   4 +-
 library/Class/Auth/IdentityProvider.php       |  12 +-
 library/Class/Auth/NotLogged.php              |  11 +-
 library/Class/IdentityProvider.php            |  39 ++++--
 library/Class/IdentityProvider/Default.php    |   7 +-
 library/Class/IdentityProvider/Types.php      |  43 ++++++-
 library/Class/User/Identity.php               |  14 ++
 library/Class/WebService/Acheteza.php         |  79 ++----------
 library/Class/WebService/IdentityProvider.php | 113 ++++++++++++++++
 library/Class/{ => WebService}/OpenId.php     | 111 +++++++---------
 library/Trait/TimeSource.php                  |   4 +-
 library/ZendAfi/Session/Namespace.php         |  56 ++++++++
 .../IdentityProviderAuthenticationTest.php    | 121 +++++++++++-------
 17 files changed, 446 insertions(+), 220 deletions(-)
 create mode 100644 library/Class/WebService/IdentityProvider.php
 rename library/Class/{ => WebService}/OpenId.php (72%)
 create mode 100644 library/ZendAfi/Session/Namespace.php

diff --git a/application/modules/admin/views/scripts/identity-providers/index.phtml b/application/modules/admin/views/scripts/identity-providers/index.phtml
index b321c499fe6..887301a9cfb 100644
--- a/application/modules/admin/views/scripts/identity-providers/index.phtml
+++ b/application/modules/admin/views/scripts/identity-providers/index.phtml
@@ -1,10 +1,18 @@
 <?php
 
-echo $this->Button_New((new Class_Entity())->setText($this->_('Ajouter un fournisseur d\'identité')));
+echo $this->Button_New((new Class_Entity())
+                       ->setText($this->_('Ajouter un fournisseur d\'identité')));
 
-echo $this->renderTable(
-  (new Class_TableDescription('providers'))
-              ->addColumn($this->_('Libellé'), ['attribute' => 'label'])
-              ->addColumn($this->_('Url de retour'), ['attribute' => 'callback_url'])
-              ->addRowAction(function($model) { return $this->renderPluginsActions($model); }),
-  $this->providers);
+echo $this
+  ->renderTable((new Class_TableDescription('providers'))
+                ->addColumn($this->_('Libellé'), ['attribute' => 'label'])
+                ->addColumn($this->_('Url de retour'), ['attribute' => 'callback_url'])
+                ->addColumn($this->_('Actif ?'),
+                            function($model)
+                            {
+                              return $model->getActive()
+                                ? $this->_('Oui')
+                                : $this->_('Non');
+                            })
+                ->addRowAction(function($model) { return $this->renderPluginsActions($model); }),
+                $this->providers);
diff --git a/application/modules/opac/controllers/AbonneController.php b/application/modules/opac/controllers/AbonneController.php
index 7114e856004..d22b11d7ccd 100644
--- a/application/modules/opac/controllers/AbonneController.php
+++ b/application/modules/opac/controllers/AbonneController.php
@@ -1223,7 +1223,20 @@ class AbonneController extends ZendAfi_Controller_Action {
   public function associatedProvidersAction() {
     $this->view->titre = $this->_('Mes comptes associés');
     $this->view->identities = Class_User_Identity::findIdentitiesForActiveProviders($this->_user);
-    $this->view->providers = Class_IdentityProvider::findAllActiveProviders();
+  }
+
+
+  public function dissociateProviderAction() {
+    if ((!$identity = Class_User_Identity::find((int)$this->_getParam('id')))
+        || $this->_user->getId() != $identity->getUserId()) {
+      $this->_helper->notify($this->_('Impossible de dissocier une identité inconnue'));
+      return $this->_redirectToUrlOrReferer('/');
+    }
+
+    $identity->dissociate();
+    $this->_helper->notify($this->_('Votre compte a été dissocié de "%s"',
+                                    $identity->getProviderLabel()));
+    $this->_redirectToUrlOrReferer('/');
   }
 
 
diff --git a/application/modules/opac/controllers/AuthController.php b/application/modules/opac/controllers/AuthController.php
index 073aac4c787..9f1acd6dd22 100644
--- a/application/modules/opac/controllers/AuthController.php
+++ b/application/modules/opac/controllers/AuthController.php
@@ -184,16 +184,6 @@ class AuthController extends ZendAfi_Controller_Action {
   }
 
 
-  public function dissociateAction() {
-    if (!$identity = Class_User_Identity::find((int)$this->_getParam('id')))
-      return $this->_redirectToUrlOrReferer('/');
-
-    $identity->dissociate();
-    $this->_helper->notify($this->_('Votre compte a été dissocié.'));
-    $this->_redirectToUrlOrReferer('/');
-  }
-
-
   public function ajaxLoginAction(){
     $redirect = urldecode($this->_getParam('redirect'));
     $location = urldecode($this->_getParam('location'));
diff --git a/application/modules/opac/controllers/IdentityProvidersController.php b/application/modules/opac/controllers/IdentityProvidersController.php
index f7920b208ec..0af9c1af02e 100644
--- a/application/modules/opac/controllers/IdentityProvidersController.php
+++ b/application/modules/opac/controllers/IdentityProvidersController.php
@@ -41,9 +41,8 @@ class IdentityProvidersController extends ZendAfi_Controller_Action {
 
   public function logoutAction() {
     if (!$provider = Class_IdentityProvider::find((int)$this->_getParam('id')))
-      return;
+      return $this->_redirectToReferer();
 
-    return $this->_redirect((new Class_OpenId($provider))
-                            ->getLogoutUrl());
+    $this->_redirect($provider->logoutUrl());
   }
 }
diff --git a/application/modules/opac/views/scripts/abonne/associated-providers.phtml b/application/modules/opac/views/scripts/abonne/associated-providers.phtml
index 7f80030e7b5..ff1849dd502 100644
--- a/application/modules/opac/views/scripts/abonne/associated-providers.phtml
+++ b/application/modules/opac/views/scripts/abonne/associated-providers.phtml
@@ -16,8 +16,8 @@ $description = (new Class_TableDescription('active_identities'))
   ->addColumn($this->_('Dissocier'),
               function($model)
               {
-                return $this->tagAnchor(['controller' => 'auth',
-                                         'action' => 'dissociate',
+                return $this->tagAnchor(['controller' => 'abonne',
+                                         'action' => 'dissociate-provider',
                                          'id' => $model->getId()
                                          ],
                                         $this->_('Dissocier votre compte'));
diff --git a/library/Class/Auth/IdentityProvider.php b/library/Class/Auth/IdentityProvider.php
index b58c70be1ad..609721779fa 100644
--- a/library/Class/Auth/IdentityProvider.php
+++ b/library/Class/Auth/IdentityProvider.php
@@ -92,16 +92,19 @@ class Class_Auth_IdentityProviderNotLogged extends Class_Auth_NotLogged {
 
   protected function _doOnLoginSuccess() {
     if (!$provider = $this->_getProvider()) {
-      $this->controller->notify($this->_('Association de compte impossible : fournisseur inconnu'));
+      $this->controller->notify($this->_('Impossible d\'associer votre compte à un fournisseur inconnu'));
       return parent::_doOnLoginSuccess();
     }
 
     if (!$provider->associate(Class_Users::getIdentity())) {
-      $this->controller->notify($this->_('L\'association de compte vers "%s" a échoué',
+      $this->controller->notify($this->_('L\'association de votre compte à "%s" a échoué',
                                          $provider->getLabel()));
       return parent::_doOnLoginSuccess();
     }
 
+    $this->controller->notify($this->_('L\'association de votre compte à "%s" a réussi',
+                                       $provider->getLabel()));
+
     if ($url = $provider->loginSuccessRedirectUrl())
       $this->setRedirectUrl($url);
 
@@ -109,6 +112,11 @@ class Class_Auth_IdentityProviderNotLogged extends Class_Auth_NotLogged {
   }
 
 
+  protected function _clearRemoteLogins() {
+    // do not clear as we are meant to keep remote login
+  }
+
+
   protected function _doOnLoginFail() {
     $this->redirect_url = '';
   }
diff --git a/library/Class/Auth/NotLogged.php b/library/Class/Auth/NotLogged.php
index dc91f2e0bca..80f1c821950 100644
--- a/library/Class/Auth/NotLogged.php
+++ b/library/Class/Auth/NotLogged.php
@@ -25,11 +25,20 @@ class Class_Auth_NotLogged extends Class_Auth_Strategy {
     $this->redirect_url = $this->default_url;
 
     if (!$error = $this->controller->_authenticate())
-      return function() { return $this->_doOnLoginSuccess(); };
+      return function() {
+        // login without associating remote identity should clear to prevent messing with remote ids afterwards
+        $this->_clearRemoteLogins();
+        return $this->_doOnLoginSuccess();
+      };
 
     return function() use($error) {
       $this->controller->notify($error);
       return $this->_doOnLoginFail();
     };
   }
+
+
+  protected function _clearRemoteLogins() {
+    (new Class_IdentityProvider_Types)->clearRemoteLogin();
+  }
 }
diff --git a/library/Class/IdentityProvider.php b/library/Class/IdentityProvider.php
index a871910cc5c..d3e888743ff 100644
--- a/library/Class/IdentityProvider.php
+++ b/library/Class/IdentityProvider.php
@@ -20,27 +20,35 @@
  */
 
 
-
 class IdentityProviderLoader extends Storm_Model_Loader {
+  protected $_actives;
+
   public function findAllActiveProviders() {
     return Class_IdentityProvider::findAllBy(['active' => 1]);
+
+    return isset($this->_actives)
+      ? $this->_actives
+      : $this->_actives = Class_IdentityProvider::findAllBy(['active' => 1]);
   }
 }
 
 
 
+
 class Class_IdentityProvider extends Storm_Model_Abstract{
   protected
     $_table_name = 'identity_provider',
-    $_table_primary = 'id',
     $_loader_class = 'IdentityProviderLoader',
     $_default_attribute_values = ['label' => '',
                                   'config' => '',
                                   'active' => true,
                                   'type' => '',
-                                  'protocol' => 'openid_connect_1'
-    ],
-    $_identifier = '',
+                                  'protocol' => 'openid_connect_1'],
+
+    $_has_many = ['user_identities' => ['model' => 'Class_User_Identity',
+                                        'role' => 'provider',
+                                        'dependents' => 'delete'] ],
+
     $_config_fields = ['url',
                        'url_api',
                        'client_id',
@@ -50,9 +58,7 @@ class Class_IdentityProvider extends Storm_Model_Abstract{
                        'button_login',
                        'button_logout'],
 
-    $_context,
-    $_type_class
-    ;
+    $_context;
 
 
   public function setContext($context) {
@@ -66,8 +72,23 @@ class Class_IdentityProvider extends Storm_Model_Abstract{
   }
 
 
+  public function getServiceClass() {
+    return $this->getTypeClass()->getServiceClass();
+  }
+
+
   public function isAttachable() {
-    return !(Class_Users::getIdentity() && $this->isRemotelyLogged());
+    $user = Class_Users::getIdentity();
+    return !($user && $this->isRemotelyLogged())
+      && !$this->isAssociatedTo($user);
+  }
+
+
+  public function isAssociatedTo($user) {
+    return ($user && !$this->isNew())
+      ? 0 < Class_User_Identity::countBy(['provider_id' => $this->getId(),
+                                          'user_id' => $user->getId()])
+      : false;
   }
 
 
diff --git a/library/Class/IdentityProvider/Default.php b/library/Class/IdentityProvider/Default.php
index a21d549bff4..c29385d748c 100644
--- a/library/Class/IdentityProvider/Default.php
+++ b/library/Class/IdentityProvider/Default.php
@@ -24,7 +24,7 @@ class Class_IdentityProvider_Default {
   protected
     $_config = [],
     $_service,
-    $_service_class = 'Class_OpenId',
+    $_service_class = 'Class_WebService_OpenId',
     $_provider;
 
   public function __construct($provider) {
@@ -99,6 +99,11 @@ class Class_IdentityProvider_Default {
   }
 
 
+  public function getServiceClass() {
+    return $this->_service_class;
+  }
+
+
   protected function _service() {
     $class_name = $this->_service_class;
 
diff --git a/library/Class/IdentityProvider/Types.php b/library/Class/IdentityProvider/Types.php
index 5cf8804907d..db7c540de40 100644
--- a/library/Class/IdentityProvider/Types.php
+++ b/library/Class/IdentityProvider/Types.php
@@ -23,6 +23,12 @@
 class Class_IdentityProvider_Types {
   use Trait_Translator;
 
+  protected $_services;
+
+  public function __construct() {
+    $this->_services = new Storm_Collection(['Class_WebService_OpenId', 'Class_WebService_Acheteza']);
+  }
+
 
   public function asMultiOptions() {
     return ['default' => $this->_('OpenId Connect'),
@@ -42,17 +48,40 @@ class Class_IdentityProvider_Types {
 
 
   public function isLogged() {
-    foreach(['Class_OpenId', 'Class_WebService_Acheteza'] as $class_name)
-      if (call_user_func([$class_name, 'isLogged']))
-        return true;
-
-    return false;
+    return null !== $this->_services
+      ->detect(function($each) { return call_user_func([$each, 'isLogged']); });
   }
 
 
   public function clearRemoteLogin() {
-    foreach(['Class_OpenId', 'Class_WebService_Acheteza'] as $class_name)
-      call_user_func([$class_name, 'clearSession']);
+    $this->_services
+      ->eachDo(function($each) { call_user_func([$each, 'clearSession']); });
+
+    return $this;
+  }
+
+
+  public function clearUnusedRemoteIds() {
+    if (!$user = Class_Users::getIdentity())
+      return $this->clearRemoteLogin();
+
+    if (!$identities = $user->getUserIdentities())
+      return $this->clearRemoteLogin();
+
+    $map_by_service_class = [];
+    foreach($identities as $identity) {
+      if (!$service = $identity->getServiceClass())
+        continue;
+
+      if (!array_key_exists($service, $map_by_service_class))
+        $map_by_service_class[$service] = [];
+
+      $map_by_service_class[$service][] = $identity->getIdentifier();
+    }
+
+    foreach($map_by_service_class as $service_class => $ids)
+      call_user_func([$service_class, 'clearSessionIfNotIn'], $ids);
+
 
     return $this;
   }
diff --git a/library/Class/User/Identity.php b/library/Class/User/Identity.php
index 96bea376723..85c8260893a 100644
--- a/library/Class/User/Identity.php
+++ b/library/Class/User/Identity.php
@@ -67,4 +67,18 @@ class Class_User_Identity extends Storm_Model_Abstract {
     $this->delete();
     return $this;
   }
+
+
+  public function getProviderLabel() {
+    return ($provider = $this->getProvider())
+      ? $provider->getLabel()
+      : '';
+  }
+
+
+  public function getServiceClass() {
+    return ($provider = $this->getProvider())
+      ? $provider->getServiceClass()
+      : '';
+  }
 }
diff --git a/library/Class/WebService/Acheteza.php b/library/Class/WebService/Acheteza.php
index dfd35691fbe..81c13dce5e3 100644
--- a/library/Class/WebService/Acheteza.php
+++ b/library/Class/WebService/Acheteza.php
@@ -20,82 +20,29 @@
  */
 
 
-class Class_WebService_Acheteza {
-  use Trait_SimpleWebClient;
-
-  const SESSION_NAMESPACE = 'Class_WebService_Acheteza';
-
-  /** @var Zend_Session_Namespace */
-  protected static $_session_namespace;
-
-  /** @var Class_IdentityProvider */
-  protected $_provider;
-
-
-  public static function getSession() {
-    return static::$_session_namespace
-      ? static::$_session_namespace
-      : static::$_session_namespace = new Zend_Session_Namespace(md5(BASE_URL.static::SESSION_NAMESPACE));
-  }
-
-
-  public static function isLogged() {
-    return isset(static::getSession()->id);
-  }
-
-
-  public static function loginWith($id) {
-    static::getSession()->id = $id;
-  }
-
-
-  public static function clearSession() {
-    static::getSession()->unsetAll();
-  }
-
-
-  public static function logout() {
-    static::clearSession();
-  }
-
-
-  public function __construct($provider) {
-    $this->_provider = $provider;
-  }
-
-
+class Class_WebService_Acheteza extends Class_WebService_IdentityProvider {
   public function validate($context) {
+    if (!$context)
+      return $this;
+
     if ((!$token = $context->getParam('access_token'))
         || (!$pan = $context->getParam('pan')))
       return $this;
 
-    if (!$this->_checkAccessToken($token, $pan))
+    /*
+      if (!$this->_checkAccessToken($token, $pan))
       return $this;
+    */
 
-    if ($id = $this->_getRemoteId($token))
-      $this->loginWith($id);
-
+    $this->loginWith($pan);
     return $this;
-  }
-
-
-  public function getUserId() {
-    return $this->getSession()->id;
-  }
-
-
-  public function remoteName() {
-    return '';
-  }
-
-
-  public function loginFormAction($params, $context) {
-    return $params;
-  }
 
+    /*
+      if ($id = $this->_getRemoteId($token))
+      $this->loginWith($id);
+    */
 
-  public function getLoginSuccessRedirectUrl() {
-    return '';
+    return $this;
   }
 
 
diff --git a/library/Class/WebService/IdentityProvider.php b/library/Class/WebService/IdentityProvider.php
new file mode 100644
index 00000000000..a44eb7cc8bd
--- /dev/null
+++ b/library/Class/WebService/IdentityProvider.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Copyright (c) 2012-2020, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH 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 BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+abstract class Class_WebService_IdentityProvider {
+  use Trait_SimpleWebClient, Trait_TimeSource;
+
+  /** @var ZendAfi_Session_Namespace */
+  protected static $_session_namespace;
+
+  /** @var Class_IdentityProvider */
+  protected $_provider;
+
+  /** @return ZendAfi_Session_Namespace */
+  public static function getSession() {
+    if (static::$_session_namespace)
+      return static::$_session_namespace;
+
+    static::$_session_namespace = (new ZendAfi_Session_Namespace(md5(BASE_URL . get_called_class())))
+      ->setExpirationDelay(300);
+
+    return static::$_session_namespace;
+  }
+
+
+  public static function clearSession() {
+    static::getSession()->unsetAll();
+  }
+
+
+  public static function clearSessionIfNotIn($ids) {
+    if (!$ids || !in_array(static::getUserId(), $ids))
+      static::clearSession();
+  }
+
+
+  public static function getUserId() {
+    return static::getSession()->id;
+  }
+
+
+  public static function isLogged() {
+    return isset(static::getSession()->id);
+  }
+
+
+  public static function loginWith($id) {
+    static::getSession()->id = $id;
+  }
+
+
+  public function __construct($provider) {
+    $this->_provider = $provider;
+  }
+
+
+  /** @param $context Zend_Controller_Request_Abstract */
+  public function validate($context) {
+    return $this;
+  }
+
+
+  /**
+   * @param $params array
+   * @param $context Zend_Controller_Request_Abstract
+   * @return array
+   */
+  public function loginFormAction($params, $context) {
+    return $params;
+  }
+
+
+  /** @return string */
+  public function remoteName() {
+    return '';
+  }
+
+
+  /** @return string */
+  public function getAuthorizeUrl() {
+    return '';
+  }
+
+
+  /** @return string */
+  public function getLoginSuccessRedirectUrl() {
+    return '';
+  }
+
+
+  /** @return string */
+  public function getLogoutUrl($redirect) {
+    return '';
+  }
+}
diff --git a/library/Class/OpenId.php b/library/Class/WebService/OpenId.php
similarity index 72%
rename from library/Class/OpenId.php
rename to library/Class/WebService/OpenId.php
index 52e288915b4..f6676d125ff 100644
--- a/library/Class/OpenId.php
+++ b/library/Class/WebService/OpenId.php
@@ -18,14 +18,13 @@
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
-require_once('library/php-jwt/autoload.php');
-require_once('library/phpseclib/autoload.php');
 
-class Class_OpenId {
-  use Trait_SimpleWebClient;
+require_once 'library/php-jwt/autoload.php';
+require_once 'library/phpseclib/autoload.php';
+
+class Class_WebService_OpenId extends Class_WebService_IdentityProvider {
 
   protected
-    $_provider,
     $_client_id,
     $_client_secret = '' ,
     $_url='',
@@ -40,29 +39,30 @@ class Class_OpenId {
 
 
 
-  public static function clearSession() {
-    Zend_Registry::get('session')->openId=[];
+  public static function isLogged() {
+    return isset(static::getSession()->userinfo);
   }
 
 
-  public static function getSession() {
-    $session = Zend_Registry::get('session');
-    if (!$session->openId)
-      $session->openId = [];
+  public static function getUserId() {
+    if (!static::isLogged())
+      return null;
 
-    return $session;
-  }
+    if (!$user_info = static::getSession()->userinfo)
+      return null;
 
+    if (isset($user_info->openid))
+      return $user_info->openid;
 
-  public static function isLogged() {
-    return isset(static::getSession()->openId['userinfo']);
+    return isset($user_info->sub)
+      ? $user_info->sub
+      : null;
   }
 
 
-  public function __construct($provider, $redirect_url='') {
-    $this->_provider = $provider;
-    if ($redirect_url)
-      $this->_redirect_url = $redirect_url;
+  public function __construct($provider) {
+    parent::__construct($provider);
+
     $this->_client_id = $provider->getClientId();
     $this->_client_secret = $provider->getClientSecret();
     $this->_nonce = true;//$provider->getNonce();
@@ -70,6 +70,7 @@ class Class_OpenId {
     $this->_logout_url = $provider->getLogoutUrl()
       ? $provider->getLogoutUrl()
       : $this->_url.'/logout';
+
     if (!$this->getConfiguration())
       $this->_setDefaultConfiguration();
   }
@@ -85,6 +86,7 @@ class Class_OpenId {
       return $this;
 
     $this->getUserInfos($code, $state);
+    return $this;
   }
 
 
@@ -100,8 +102,8 @@ class Class_OpenId {
 
 
   public function remoteName() {
-    return isset($this->getSession()->openId['userinfo'])
-      ? $this->getSession()->openId['userinfo']->name
+    return isset($this->getSession()->userinfo)
+      ? $this->getSession()->userinfo->name
       : '';
   }
 
@@ -133,8 +135,8 @@ class Class_OpenId {
 
 
   public function getUserInfos($code, $state) {
-    if (isset($this->getSession()->openId['userinfo']))
-      return $this->getSession()->openId['userinfo'];
+    if (isset($this->getSession()->userinfo))
+      return $this->getSession()->userinfo;
 
     if ($state != $this->getState())
       return false;
@@ -142,35 +144,19 @@ class Class_OpenId {
     if (!$access_token = $this->_getAndCheckAccessToken($code))
       return [];
 
-    $this->getSession()->openId['userinfo'] = $this->_getInfoFromFI($access_token);
+    $this->getSession()->userinfo = $this->_getInfoFromFI($access_token);
 
-    return $this->getSession()->openId['userinfo'];
+    return $this->getSession()->userinfo;
   }
 
 
   protected function _getToken() {
-    return isset($this->getSession()->openId['token']) ?
-      $this->getSession()->openId['token']
+    return isset($this->getSession()->token)
+      ? $this->getSession()->token
       : '';
   }
 
 
-  public function getUserId() {
-    if (!isset($this->getSession()->openId['userinfo']))
-      return null;
-
-    if (!$user_info = $this->getSession()->openId['userinfo'])
-      return null;
-
-    if (isset($user_info->openid))
-      return $user_info->openid;
-
-    return isset($user_info->sub)
-      ? $user_info->sub
-      : null;
-  }
-
-
   protected function _getTokenUrl() {
     return $this->_token_url;
   }
@@ -187,8 +173,8 @@ class Class_OpenId {
 
 
   protected function _getAndCheckAccessToken($code) {
-    if (isset($this->getSession()->openId['access_token']))
-      return $this->getSession()->openId['access_token'];
+    if (isset($this->getSession()->access_token))
+      return $this->getSession()->access_token;
 
     $http_client = $this->getWebClient();
     $post_data = [
@@ -206,7 +192,7 @@ class Class_OpenId {
     if (!isset($json->access_token)|| !isset($json->id_token))
       return ;
 
-    $this->getSession()->openId['access_token']=$json->access_token;
+    $this->getSession()->access_token = $json->access_token;
 
     if (!$this->_nonce)
       return $json->access_token;
@@ -224,7 +210,7 @@ class Class_OpenId {
     if (!$this->_checkNonce($response->nonce))
       return ;
 
-    $this->getSession()->openId['token'] = $json->id_token;
+    $this->getSession()->token = $json->id_token;
     return $json->access_token;
   }
 
@@ -250,36 +236,37 @@ class Class_OpenId {
 
 
   public function getState() {
-    if (isset($this->getSession()->openId['state']) &&
-        ($state = $this->getSession()->openId['state']))
+    if (isset($this->getSession()->state)
+        && ($state = $this->getSession()->state))
       return $state;
 
-    return $this->getSession()->openId['state'] = $this->_redirect_url ? base64_encode($this->_redirect_url) : $this->_getRandomToken();
+    return $this->getSession()->state = $this->_redirect_url
+      ? base64_encode($this->_redirect_url)
+      : $this->_getRandomToken();
   }
 
 
   public function getNonce() {
-    if (isset($this->getSession()->openId['nonce']) &&
-        ($nonce = $this->getSession()->openId['nonce']))
+    if (isset($this->getSession()->nonce)
+        && ($nonce = $this->getSession()->nonce))
       return $nonce;
 
-    return $this->getSession()->openId['nonce'] = $this->_getRandomToken();
+    return $this->getSession()->nonce = $this->_getRandomToken();
   }
 
 
   protected function _getRandomToken(){
-    return sha1(mt_rand(0,mt_getrandmax()));
+    return sha1(mt_rand(0, mt_getrandmax()));
   }
 
 
   public function getRedirectLoginUrl() {
-    return (new Class_Url)->absoluteUrl(['module' => 'opac',
-                                         'controller' => 'auth',
-                                         'action' => 'login',
-                                         'provider' => $this->_provider->getId(),
-                                         ],
-                                        null,
-                                        true);
+    return Class_Url::absolute(['module' => 'opac',
+                                'controller' => 'auth',
+                                'action' => 'login',
+                                'provider' => $this->_provider->getId(),
+                                ],
+                               null, true);
   }
 
 
@@ -297,7 +284,7 @@ class Class_OpenId {
 
 
   protected function _getBokehLogoutUrl() {
-    return (new Class_Url)->absoluteUrl([], null,true);
+    return Class_Url::absolute([], null, true);
   }
 
 
diff --git a/library/Trait/TimeSource.php b/library/Trait/TimeSource.php
index 973d3e39e27..f8530a64fcf 100644
--- a/library/Trait/TimeSource.php
+++ b/library/Trait/TimeSource.php
@@ -47,6 +47,7 @@ trait Trait_TimeSource {
     return date('Y-m-d H:i:s',self::getTimeSource()->time());
   }
 
+
   public static function substractYearsToCurrentDate($days) {
     return date('Y-m-d',strtotime('-'.(string)$days.' year', self::getTimeSource()->time()));
   }
@@ -57,6 +58,7 @@ trait Trait_TimeSource {
     return $now;
   }
 
+
   /** @return Class_TimeSource */
   public static function getTimeSource() {
     if (null == self::$_time_source)
@@ -70,5 +72,3 @@ trait Trait_TimeSource {
     self::$_time_source = $time_source;
   }
 }
-
-?>
\ No newline at end of file
diff --git a/library/ZendAfi/Session/Namespace.php b/library/ZendAfi/Session/Namespace.php
new file mode 100644
index 00000000000..851dad44001
--- /dev/null
+++ b/library/ZendAfi/Session/Namespace.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH 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 BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Session_Namespace extends Zend_Session_Namespace {
+  use Trait_TimeSource;
+
+  const EXPIRATION_VAR_NAME = 'expires_at';
+
+  protected $_expiration_delay;
+
+  public function __construct($namespace = 'Default', $singleInstance = false) {
+    parent::__construct($namespace, $singleInstance);
+    if ($this->_isExpired())
+      $this->unsetAll();
+  }
+
+
+  public function __set($name, $value) {
+    if ($this->_expiration_delay)
+      parent::__set(static::EXPIRATION_VAR_NAME,
+                    $this->getTimeSource()->time() + $this->_expiration_delay);
+
+    return parent::__set($name, $value);
+  }
+
+
+  public function setExpirationDelay($seconds) {
+    $this->_expiration_delay = (int)$seconds;
+    return $this;
+  }
+
+
+  protected function _isExpired() {
+    $expires_at = $this->__get(static::EXPIRATION_VAR_NAME);
+    return isset($expires_at) && $this->getTimeSource()->time() > $expires_at;
+  }
+}
diff --git a/tests/scenarios/IdentityProvider/IdentityProviderAuthenticationTest.php b/tests/scenarios/IdentityProvider/IdentityProviderAuthenticationTest.php
index 1649057d446..ad488051508 100644
--- a/tests/scenarios/IdentityProvider/IdentityProviderAuthenticationTest.php
+++ b/tests/scenarios/IdentityProvider/IdentityProviderAuthenticationTest.php
@@ -115,7 +115,7 @@ class IdentityProviderAuthenticationBoxDisplayTest extends AbstractControllerTes
                                              'client_secret' => '9876'])
                    ]);
 
-    Class_OpenId::setWebClient($this->mock()->whenCalled('open_url')->answers(''));
+    Class_WebService_OpenId::setWebClient($this->mock()->whenCalled('open_url')->answers(''));
 
     $this->dispatch('/');
   }
@@ -300,7 +300,7 @@ abstract class IdentityProviderAuthenticationCallbackAuthenticationTestCase
       ->whenCalled('postRawData')
       ->answers($response_token);
 
-    Class_OpenId::setWebClient($web_client);
+    Class_WebService_OpenId::setWebClient($web_client);
 
     $this->fixture('Class_Users',
                    ['id' => 33,
@@ -320,8 +320,8 @@ abstract class IdentityProviderAuthenticationCallbackAuthenticationTestCase
 
 
   public function tearDown() {
-    Class_OpenId::setWebClient(null);
-    Zend_Registry::get('session')->unsetAll();
+    Class_WebService_OpenId::setWebClient(null);
+    Class_WebService_OpenId::clearSession();
     parent::tearDown();
   }
 }
@@ -335,9 +335,7 @@ class IdentityProviderAuthenticationConnectedOpenidUserTest
   public function setUp() {
     parent::setUp();
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
-
-    $session = Zend_Registry::get('session');
-    $session->openId['userinfo'] = (object)['sub' => '1232'];
+    Class_WebService_OpenId::getSession()->userinfo = (object)['sub' => '1232'];
 
     $simple_widgets = ['modules' => ['1' => ['division' => '1',
                                              'type_module' => 'IDENTITY_PROVIDER',
@@ -384,7 +382,6 @@ class IdentityProviderAuthenticationLoggedUserTest extends IdentityProviderAuthe
   public function setUp() {
     parent::setUp();
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
-    Zend_Registry::get('session')->openId = [];
     $simple_widgets = ['modules' => ['1' => ['division' => '1',
                                              'type_module' => 'IDENTITY_PROVIDER',
                                              'preferences' => []],
@@ -421,8 +418,9 @@ class IdentityProviderAuthenticationShouldDisplayButtonWithFCUrl
   public function authenticationToFranceConnectShouldRedirectToFranceConnectAuthorize() {
     $_SERVER['HTTP_REFERER'] = 'referer';
     $this->_state = base64_encode('http://bokeh.org/mycurrenturl');
-    Zend_Registry::get('session')->openId = ['state' => $this->_state,
-                                             'nonce' => $this->_nonce];
+    $session = Class_WebService_OpenId::getSession();;
+    $session->state = $this->_state;
+    $session->nonce = $this->_nonce;
 
     $this->dispatch('/identity-providers/authenticate/id/1/redirect/'.urlencode('http://bokeh.org/mycurrenturl'));;
 
@@ -447,9 +445,10 @@ class IdentityProviderAuthenticationCallbackAuthenticationFirstTime
     ZendAfi_Auth::getInstance()->clearIdentity();
 
     $this->_state = base64_encode('http://bokeh.org/mycurrenturl');
-    Zend_Registry::get('session')->openId = ['state' => $this->_state,
-                                             'nonce' => $this->_nonce,
-                                             'access_token' => '1111'];
+    $session = Class_WebService_OpenId::getSession();;
+    $session->state = $this->_state;
+    $session->nonce = $this->_nonce;
+    $session->access_token = 1111;
 
     $this->fixture('Class_IdentityProvider',
                    ['id' => 12,
@@ -484,13 +483,15 @@ class IdentityProviderAuthenticationCallbackAuthenticationFirstTime
 
 
 
-class IdentityProviderAuthenticationConnectedUser extends IdentityProviderAuthenticationCallbackAuthenticationTestCase {
+class IdentityProviderAuthenticationConnectedUser
+  extends IdentityProviderAuthenticationCallbackAuthenticationTestCase {
+
   public function setUp() {
     parent::setUp();
-    Zend_Registry::get('session')->openId = ['state' => $this->_state,
-                                             'nonce' => $this->_nonce];
-    Zend_Registry::get('session')->openId['userinfo'] = (object)['sub' => '9763',
-                                                                 'name' => 'Albator'];
+    $session = Class_WebService_OpenId::getSession();
+    $session->state = $this->_state;
+    $session->nonce = $this->_nonce;
+    $session->userinfo = (object)['sub' => '9763', 'name' => 'Albator'];
     $this->fixture('Class_User_Identity',
                    ['id' => 1,
                     'provider_id' => 1,
@@ -503,7 +504,7 @@ class IdentityProviderAuthenticationConnectedUser extends IdentityProviderAuthen
 
   /** @test */
   public function ifUserExistsWithOpenidThenShouldBeConnected() {
-    $this->assertEquals('Albator',Class_Users::getIdentity()->getLogin());
+    $this->assertEquals('Albator', Class_Users::getIdentity()->getLogin());
     $this->assertRedirectTo('http://bokeh.org/mycurrenturl');
   }
 }
@@ -524,11 +525,9 @@ class IdentityProviderAuthenticationDissociateUserTest
                     'identifier' => '9763']);
 
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
+    Class_WebService_OpenId::getSession()->userinfo = (object)['sub' => '9763'];
 
-    $session = Zend_Registry::get('session');
-    $session->openId['userinfo'] = (object)['sub' => '9763'];
-
-    $this->dispatch('/auth/dissociate/id/1');
+    $this->dispatch('/abonne/dissociate-provider/id/1');
   }
 
 
@@ -546,8 +545,7 @@ class IdentityProviderAuthenticationDissociateUserTest
 
   /** @test */
   public function sessionShouldBeCleared() {
-    $session = Zend_Registry::get('session');
-    $this->assertEmpty($session->openId);
+    $this->assertEmpty(Class_WebService_OpenId::getSession()->getIterator());
   }
 }
 
@@ -559,9 +557,10 @@ class IdentityProviderAuthenticationCallbackAuthentication
 
   public function setUp() {
     parent::setup();
-    Zend_Registry::get('session')->openId = ['state' => $this->_state,
-                                             'nonce' => $this->_nonce,
-                                             'access_token' => 1111];
+    $session = Class_WebService_OpenId::getSession();
+    $session->state = $this->_state;
+    $session->nonce = $this->_nonce;
+    $session->access_token = 1111;
 
     $this->fixture('Class_IdentityProvider',
                    ['id' => 12,
@@ -615,23 +614,24 @@ class IdentityProviderAuthenticationCallbackAuthentication
 
   /** @test */
   public function logoutShouldRemoveOpenIdInSession() {
-    Zend_Registry::get('session')->openId = ['userinfo' => '999'];
+    Class_WebService_OpenId::getSession()->userinfo = '999';
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
     $this->dispatch('/auth/logout');
-    $this->assertEquals([], Zend_Registry::get('session')->openId);
+    $this->assertEmpty(Class_WebService_OpenId::getSession()->getIterator());
   }
 
 
   /** @test */
   public function logoutProviderShouldRedirectToProviderLogout() {
-    Zend_Registry::get('session')->openId = ['userinfo' => '999',
-                                             'state' => '6',
-                                             'token' => 'mytoken'];
+    $session = Class_WebService_OpenId::getSession();
+    $session->state = '6';
+    $session->userinfo = '999';
+    $session->token = 'mytoken';
 
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
     $this->dispatch('/auth/logout/provider/1');
 
-    $this->assertEquals([], Zend_Registry::get('session')->openId);
+    $this->assertEmpty(Class_WebService_OpenId::getSession()->getIterator());
     $this->assertRedirectTo('https://fcp.integ01.dev-franceconnect.fr/api/v1/logout?id_token_hint=mytoken&state=6&post_logout_redirect_uri='.urlencode(Class_Url::absolute([], null, true)));
     $this->assertEmpty(Class_Users::getIdentity());
   }
@@ -658,7 +658,7 @@ class IdentityProviderAuthenticationAbonneListTest
                    ]);
 
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
-    Class_OpenId::getWebClient()
+    Class_WebService_OpenId::getWebClient()
       ->whenCalled('open_url')
       ->with('http://afi.fr/.well-known/openid-configuration')
       ->answers('');
@@ -676,33 +676,34 @@ class IdentityProviderAuthenticationAbonneListTest
   public function withAssociatefranceConnectShouldBeDisplayed() {
     $this->_associateUser();
     $this->dispatch('/abonne/associated-providers');
-    $this->assertXPathContentContains('//table//td','FranceConnect', $this->_response->getBody());
+    $this->assertXPathContentContains('//table//td', 'FranceConnect', $this->_response->getBody());
   }
 
 
   /** @test */
-  public function withConnectedAndAssociateFCAfiShouldNotBeDisplayed() {
+  public function withAssociateDissociateLinkForFranceConnectShouldBeDisplayed() {
     $this->_associateUser();
-    $session = Zend_Registry::get('session');
-    $session->openId['userinfo'] = (object)['sub' => '1232'];
     $this->dispatch('/abonne/associated-providers');
-    $this->assertNotXPathContentContains('//button','Afi', $this->_response->getBody());
+    $this->assertXPathContentContains('//a[contains(@href, "/abonne/dissociate-provider/id/1")]',
+                                      'Dissocier',
+                                      $this->_response->getBody());
   }
 
 
   /** @test */
-  public function withNoConnectionAfiShouldNotBeDisplayed() {
+  public function withConnectedAndAssociateFCAfiShouldNotBeDisplayed() {
     $this->_associateUser();
+    Class_WebService_OpenId::getSession()->userinfo = (object)['sub' => '1232'];
     $this->dispatch('/abonne/associated-providers');
-    $this->assertXPathContentContains('//button','Afi', $this->_response->getBody());
+    $this->assertNotXPathContentContains('//button','Afi', $this->_response->getBody());
   }
 
 
   /** @test */
-  public function withAssociateDissociateLinkForFranceConnectShouldBeDisplayed() {
+  public function withNoConnectionAfiShouldBeDisplayed() {
     $this->_associateUser();
     $this->dispatch('/abonne/associated-providers');
-    $this->assertXPathContentContains('//a','Dissocier', $this->_response->getBody());
+    $this->assertXPathContentContains('//button','Afi', $this->_response->getBody());
   }
 }
 
@@ -749,7 +750,7 @@ abstract class IdentityProviderAuthenticationAchetezaTestCase
 
   public function tearDown() {
     Class_WebService_Acheteza::setWebClient(null);
-    Class_WebService_Acheteza::logout();
+    Class_WebService_Acheteza::clearSession();
     parent::tearDown();
   }
 }
@@ -864,10 +865,12 @@ class IdentityProviderAuthenticationAuthLoginWithPanAchetezaTest
                 ->whenCalled('isError')->answers(false)
                 ->whenCalled('getBody')->answers('{"success":"true"}'))
 
+      /*
       ->whenCalled('open_url')
       ->with('http://api.crm.server.com/api/v2.3/me',
              ['headers' => ['Authorization: Bearer MYACCESSTOKEN']])
       ->answers(json_encode(['id' => '95fbcb97-6461-48de-8923-ef4f1de30409']))
+      */
       ;
 
     $this->dispatch('/auth/login/provider/5?access_token=MYACCESSTOKEN&pan=8839');
@@ -875,8 +878,9 @@ class IdentityProviderAuthenticationAuthLoginWithPanAchetezaTest
 
 
   /** @test */
-  public function shouldBecomeRemotelyLogged() {
+  public function shouldBecomeRemotelyLoggedWithPanAsId() {
     $this->assertTrue(Class_IdentityProvider::find(5)->isRemotelyLogged());
+    $this->assertEquals('8839', Class_IdentityProvider::find(5)->getRemoteUserId());
   }
 
 
@@ -921,6 +925,12 @@ class IdentityProviderAuthenticationAuthLoginPostRemotelyLoggedAchetezaTest
   public function shouldRedirect() {
     $this->assertRedirect();
   }
+
+
+  /** @test */
+  public function shouldNotifyAssociationComplete() {
+    $this->assertFlashMessengerContentContains('L\'association de votre compte à "Acheteza" a réussi');
+  }
 }
 
 
@@ -974,7 +984,7 @@ class IdentityProviderAuthenticationDissociateAchetezaTest
     ZendAfi_Auth::getInstance()->logUser(Class_Users::find(33));
     Class_WebService_Acheteza::loginWith(9763);
 
-    $this->dispatch('/auth/dissociate/id/1');
+    $this->dispatch('/abonne/dissociate-provider/id/1');
   }
 
 
@@ -1081,4 +1091,21 @@ class IdentityProviderAuthenticationAuthLoginSigbAchetezaTest
 
     $this->assertRedirectTo('http://moncompte.server.com/ServiceLogout?' . http_build_query($params));
   }
+}
+
+
+
+
+class IdentityProviderAuthenticationExpiringSessionAchetezaTest
+  extends IdentityProviderAuthenticationAchetezaTestCase {
+
+  /** @test */
+  public function notLoggedRemoteIdShouldBeCleared() {
+    ZendAfi_Session_Namespace::setTimeSource(new TimeSourceForTest('2020-02-17 10:20:38'));
+    Class_WebService_Acheteza::loginWith(9763);
+    // jump to future past expiration time of 300s
+    ZendAfi_Session_Namespace::setTimeSource(new TimeSourceForTest('2020-02-17 10:55:38'));
+    $this->dispatch('/opac/index/index');
+    $this->assertNull(Class_IdentityProvider::find(5)->getRemoteUserId());
+  }
 }
\ No newline at end of file
-- 
GitLab