From 1741f08d4f446ca92249f788a1adfceb18b097be Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Fri, 21 Jan 2022 11:46:46 +0100
Subject: [PATCH 2/2] profile: add csam account-(un)linking capabilities
 (#60837)

---
 src/authentic2_auth_fedict/app_settings.py    |   4 +
 src/authentic2_auth_fedict/authenticators.py  |   2 -
 src/authentic2_auth_fedict/backends.py        |  71 +++++
 .../authentic2_auth_fedict/profile.html       |  27 +-
 src/authentic2_auth_fedict/urls.py            |   1 +
 src/authentic2_auth_fedict/views.py           |  16 +-
 tests/conftest.py                             |   1 +
 tests/metadata.xml                            |  19 ++
 tests/saml.crt                                |  20 ++
 tests/saml.key                                |  28 ++
 tests/settings.py                             |  23 +-
 tests/test_all.py                             | 277 +++++++++++++++++-
 tests/utils.py                                |   2 +-
 13 files changed, 480 insertions(+), 11 deletions(-)
 create mode 100644 tests/metadata.xml
 create mode 100644 tests/saml.crt
 create mode 100644 tests/saml.key

diff --git a/src/authentic2_auth_fedict/app_settings.py b/src/authentic2_auth_fedict/app_settings.py
index fc62c4c..e6efb17 100644
--- a/src/authentic2_auth_fedict/app_settings.py
+++ b/src/authentic2_auth_fedict/app_settings.py
@@ -36,6 +36,10 @@ class AppSettings:
     def enable(self):
         return self._setting('ENABLE', False)
 
+    @property
+    def PROVISION_VERIFIED_ATTRIBUTES(self):
+        return self._setting('PROVISION_VERIFIED_ATTRIBUTES', ('first_name', 'last_name', 'email', 'title'))
+
 
 import sys
 
diff --git a/src/authentic2_auth_fedict/authenticators.py b/src/authentic2_auth_fedict/authenticators.py
index bd83e33..a18425d 100644
--- a/src/authentic2_auth_fedict/authenticators.py
+++ b/src/authentic2_auth_fedict/authenticators.py
@@ -49,8 +49,6 @@ class FedictAuthenticator(BaseAuthenticator):
     def profile(self, request, *args, **kwargs):
         context = kwargs.get('context', {}).copy()
         user_saml_identifiers = request.user.saml_identifiers.all()
-        if not user_saml_identifiers:
-            return ''
         for user_saml_identifier in user_saml_identifiers:
             user_saml_identifier.idp = get_idp(user_saml_identifier.issuer)
         context['user_saml_identifiers'] = user_saml_identifiers
diff --git a/src/authentic2_auth_fedict/backends.py b/src/authentic2_auth_fedict/backends.py
index 4977d30..141e2c0 100644
--- a/src/authentic2_auth_fedict/backends.py
+++ b/src/authentic2_auth_fedict/backends.py
@@ -14,8 +14,17 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import logging
+
 import lasso
+from django.contrib import messages
+from django.utils.translation import ugettext_lazy as _
 from mellon.backends import SAMLBackend
+from mellon.utils import get_adapters, get_idp
+
+from authentic2_auth_fedict import app_settings
+
+logger = logging.getLogger(__name__)
 
 
 class FedictBackend(SAMLBackend):
@@ -28,3 +37,65 @@ class FedictBackend(SAMLBackend):
         # but we do not expose this detail to the service provider as all it
         # needs to know is "strong authentication".
         return lasso.SAML2_AUTHN_CONTEXT_SMARTCARD_PKI
+
+    def authenticate(self, request=None, **credentials):
+        saml_attributes = credentials.get('saml_attributes') or {}
+        if 'issuer' not in saml_attributes:
+            logger.debug('no idp in saml_attributes')
+            return None
+        idp = get_idp(saml_attributes['issuer'])
+        if not idp:
+            logger.debug('unknown idp %s', saml_attributes['issuer'])
+            return None
+        adapters = get_adapters(idp, request=request)
+        for adapter in adapters:
+            if not hasattr(adapter, 'authorize'):
+                continue
+            if not adapter.authorize(idp, saml_attributes):
+                return
+        for adapter in adapters:
+            if hasattr(adapter, 'lookup_by_attributes'):
+                user = adapter.lookup_by_attributes(idp, saml_attributes)
+        old_user = None
+        for adapter in adapters:
+            if not hasattr(adapter, 'lookup_user'):
+                continue
+            user = adapter.lookup_user(idp, saml_attributes)
+            if user:
+                request_user = getattr(request, 'user', None) if request else None
+                if request_user != user and request_user.is_authenticated:
+                    old_user = user
+                    user = request_user
+                    saml_identifier = old_user.saml_identifier
+                    saml_identifier.user = user
+                    saml_identifier.save(update_fields=['user'])
+                    user.saml_identifier = saml_identifier
+                    messages.warning(request, _('Your account is now linked to your eID card.'))
+                break
+        else:  # no user found
+            return
+        for adapter in adapters:
+            if not hasattr(adapter, 'provision'):
+                continue
+            adapter.provision(user, idp, saml_attributes)
+        # final identifier and attributes copy before deletion of old account
+        if old_user and old_user != user:
+            # copy all verified attributes to newly provisionned user
+            for key in app_settings.PROVISION_VERIFIED_ATTRIBUTES:
+                # corner case, email not accessible through AttributesDescriptor
+                if key == 'email':
+                    user.email = old_user.email
+                    user.email_verified = old_user.email_verified
+                    continue
+                # fallback on AttributesDescriptor-managed attributes
+                value = getattr(old_user.verified_attributes, key, None)
+                if value:
+                    setattr(user.verified_attributes, key, value)
+                else:
+                    # fallback on non-verified attributes
+                    value = getattr(old_user.attributes, key, None)
+                    if value:
+                        setattr(user.attributes, key, value)
+            logger.debug('deleting user %s, new fedict link manually created' % old_user)
+            old_user.delete()
+        return user
diff --git a/src/authentic2_auth_fedict/templates/authentic2_auth_fedict/profile.html b/src/authentic2_auth_fedict/templates/authentic2_auth_fedict/profile.html
index 7383ab7..35db1f2 100644
--- a/src/authentic2_auth_fedict/templates/authentic2_auth_fedict/profile.html
+++ b/src/authentic2_auth_fedict/templates/authentic2_auth_fedict/profile.html
@@ -1,3 +1,24 @@
-<p>
-Ce compte est relié à votre carte d'identité électronique.
-</p>
+{% load i18n static %}
+<div>
+ <img src="{% static "authentic2_auth_fedict/img/beid_image_mini.png" %}" alt="">
+{% if user_saml_identifiers %}
+ <p>{% blocktrans %}This account is linked to your eID card.{% endblocktrans %}</p>
+ <p>
+ {% blocktrans %}
+ You may want to unlink your account, although you will not be able to
+ connect to your account using your eID card (without re-doing the linking
+ procedure).
+ {% endblocktrans %}
+ </p>
+ <p><a class="button" href="{% url "fedict-unlink" %}">{% trans "Unlink my account" %}</a></p>
+{% else %}
+ <p>
+ {% blocktrans %}
+ You may want to link your existing Publik account to your eID. In order to
+ do so, you will either need your eID card and its card reader
+ or your list of personal tokens.
+ {% endblocktrans %}
+ </p>
+ <p><a class="button" href="{% url "fedict-login" %}?next=/accounts/">{% trans "Link my account to my eID card" %}</a></p>
+{% endif %}
+</div>
diff --git a/src/authentic2_auth_fedict/urls.py b/src/authentic2_auth_fedict/urls.py
index ddb6451..8f74560 100644
--- a/src/authentic2_auth_fedict/urls.py
+++ b/src/authentic2_auth_fedict/urls.py
@@ -21,4 +21,5 @@ from . import views
 urlpatterns = [
     url(r'^accounts/saml/', include('mellon.urls')),
     url(r'^accounts/fedict/login/$', views.login, name='fedict-login'),
+    url(r'^accounts/fedict/unlink/$', views.unlink, name='fedict-unlink'),
 ]
diff --git a/src/authentic2_auth_fedict/views.py b/src/authentic2_auth_fedict/views.py
index ad26f10..5377516 100644
--- a/src/authentic2_auth_fedict/views.py
+++ b/src/authentic2_auth_fedict/views.py
@@ -18,11 +18,13 @@ import random
 import urllib.parse
 
 from django.conf import settings
+from django.contrib import messages
 from django.core import signing
 from django.db import transaction
 from django.http import HttpResponseRedirect
-from django.shortcuts import resolve_url
+from django.shortcuts import redirect, resolve_url
 from django.urls import reverse
+from django.utils.translation import ugettext_lazy as _
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import View
 
@@ -69,3 +71,15 @@ class LoginView(mellon.views.LoginView):
 
 
 login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view()))
+
+
+def unlink(request):
+    if not hasattr(request, 'user') or not hasattr(request.user, 'saml_identifiers'):
+        return
+    unlink_performed = False
+    for saml_identifier in request.user.saml_identifiers.all():
+        saml_identifier.delete()
+        unlink_performed = True
+    if unlink_performed:
+        messages.success(request, message=_('Unlinking complete.'))
+    return redirect('account_management')
diff --git a/tests/conftest.py b/tests/conftest.py
index fad7911..32695e1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -50,6 +50,7 @@ def user(db):
         email_verified=True,
     )
     user.set_password('john.doe')
+    user.save()
     return user
 
 
diff --git a/tests/metadata.xml b/tests/metadata.xml
new file mode 100644
index 0000000..457f95d
--- /dev/null
+++ b/tests/metadata.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ns0:EntityDescriptor xmlns:ns0="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ns1="http://www.w3.org/2000/09/xmldsig#" entityID="https://idp.com/idp/saml2/metadata"><ns0:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><ns0:KeyDescriptor><ns1:KeyInfo><ns1:X509Data><ns1:X509Certificate>MIIDNTCCAh2gAwIBAgIUToSxnhmCYk3jdVKvMxPB8S4HvBcwDQYJKoZIhvcNAQEL
+BQAwKjEoMCYGA1UEAwwfc3BhcmUtYXV0aGVudGljLmRldi5wdWJsaWsubG92ZTAe
+Fw0yMjAxMTcxNzQ2NThaFw0zMjAxMTcxNzQ2NThaMCoxKDAmBgNVBAMMH3NwYXJl
+LWF1dGhlbnRpYy5kZXYucHVibGlrLmxvdmUwggEiMA0GCSqGSIb3DQEBAQUAA4IB
+DwAwggEKAoIBAQDlVPm7D3abG4fTh/i9VJz4s9Lplblw4OKZwJfdFj2CrZX4kehh
+zDEAcn8cE14gj5bzGCWcoeApw4KIFeoSzxZGpGn7dyiFTTny2IJugEuobWcT0IuC
+P3TcPzWrm0HXnIcZjBv6Rr/cFFa2vAKL1CjT4xgZidRjGKpQBNoT2aOnFuvTzlrz
+ZsGJ38+yRFZDQzoGS+5h4F3ZCHx6Y5fLNzWDW3Cu6/vv2/ev35omcHW+mSTVpds5
+Oo2uzIvumYAM83oLzgWRnPLnx14lH+kWoDNaXEpUWDrmLD1A9OgNYmkeT/85qdyN
+jzDbe1yG421e2jUaRXez412Nv1s1ua5U/dBlAgMBAAGjUzBRMB0GA1UdDgQWBBRQ
+Y1+34SVZXriQAFteCRbde5jSOTAfBgNVHSMEGDAWgBRQY1+34SVZXriQAFteCRbd
+e5jSOTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCwVWlzEE7w
+0wBmngfctrz6cSyc5oAqDGo6dzVw+7CYhjRGr0Z9C1zI5rYOPWdDceAqD/lOVapw
+1nawFszVpcClmY8Rt0Tiot8z18NRz0NCFZzbHx9cU5TGZUigOkuXXqB+D52ppuQB
+6t/RREpeZakiBEp0Y8pH9CalA2DCNf/2WGcvaMXQtAL7Ko0a8vPBoOMykwgaFvCj
+E+ZiZXHLLg4jw2QwX1s4Fap8vKEUn062qCx0TdgILX/PrUpmCkPvZRlA/fcQLFQw
+1vX1rInD3cpTrxNw2T9ywGYmbXG1DmpxoF5X4ju3fcTR968QVJlX3L5JMbbWNV9X
+iB4feKp2tzW1</ns1:X509Certificate></ns1:X509Data></ns1:KeyInfo></ns0:KeyDescriptor><ns0:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.com/idp/saml2/artifact" index="0"/><ns0:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.com/idp/saml2/slo" ResponseLocation="https://idp.com/idp/saml2/slo_return"/><ns0:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.com/idp/saml2/slo" ResponseLocation="https://idp.com/idp/saml2/slo_return"/><ns0:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.com/idp/saml2/slo/soap"/><ns0:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.com/idp/saml2/sso"/><ns0:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.com/idp/saml2/sso"/></ns0:IDPSSODescriptor></ns0:EntityDescriptor>
diff --git a/tests/saml.crt b/tests/saml.crt
new file mode 100644
index 0000000..85f43dc
--- /dev/null
+++ b/tests/saml.crt
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDNTCCAh2gAwIBAgIUToSxnhmCYk3jdVKvMxPB8S4HvBcwDQYJKoZIhvcNAQEL
+BQAwKjEoMCYGA1UEAwwfc3BhcmUtYXV0aGVudGljLmRldi5wdWJsaWsubG92ZTAe
+Fw0yMjAxMTcxNzQ2NThaFw0zMjAxMTcxNzQ2NThaMCoxKDAmBgNVBAMMH3NwYXJl
+LWF1dGhlbnRpYy5kZXYucHVibGlrLmxvdmUwggEiMA0GCSqGSIb3DQEBAQUAA4IB
+DwAwggEKAoIBAQDlVPm7D3abG4fTh/i9VJz4s9Lplblw4OKZwJfdFj2CrZX4kehh
+zDEAcn8cE14gj5bzGCWcoeApw4KIFeoSzxZGpGn7dyiFTTny2IJugEuobWcT0IuC
+P3TcPzWrm0HXnIcZjBv6Rr/cFFa2vAKL1CjT4xgZidRjGKpQBNoT2aOnFuvTzlrz
+ZsGJ38+yRFZDQzoGS+5h4F3ZCHx6Y5fLNzWDW3Cu6/vv2/ev35omcHW+mSTVpds5
+Oo2uzIvumYAM83oLzgWRnPLnx14lH+kWoDNaXEpUWDrmLD1A9OgNYmkeT/85qdyN
+jzDbe1yG421e2jUaRXez412Nv1s1ua5U/dBlAgMBAAGjUzBRMB0GA1UdDgQWBBRQ
+Y1+34SVZXriQAFteCRbde5jSOTAfBgNVHSMEGDAWgBRQY1+34SVZXriQAFteCRbd
+e5jSOTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCwVWlzEE7w
+0wBmngfctrz6cSyc5oAqDGo6dzVw+7CYhjRGr0Z9C1zI5rYOPWdDceAqD/lOVapw
+1nawFszVpcClmY8Rt0Tiot8z18NRz0NCFZzbHx9cU5TGZUigOkuXXqB+D52ppuQB
+6t/RREpeZakiBEp0Y8pH9CalA2DCNf/2WGcvaMXQtAL7Ko0a8vPBoOMykwgaFvCj
+E+ZiZXHLLg4jw2QwX1s4Fap8vKEUn062qCx0TdgILX/PrUpmCkPvZRlA/fcQLFQw
+1vX1rInD3cpTrxNw2T9ywGYmbXG1DmpxoF5X4ju3fcTR968QVJlX3L5JMbbWNV9X
+iB4feKp2tzW1
+-----END CERTIFICATE-----
diff --git a/tests/saml.key b/tests/saml.key
new file mode 100644
index 0000000..dc5ea6a
--- /dev/null
+++ b/tests/saml.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDlVPm7D3abG4fT
+h/i9VJz4s9Lplblw4OKZwJfdFj2CrZX4kehhzDEAcn8cE14gj5bzGCWcoeApw4KI
+FeoSzxZGpGn7dyiFTTny2IJugEuobWcT0IuCP3TcPzWrm0HXnIcZjBv6Rr/cFFa2
+vAKL1CjT4xgZidRjGKpQBNoT2aOnFuvTzlrzZsGJ38+yRFZDQzoGS+5h4F3ZCHx6
+Y5fLNzWDW3Cu6/vv2/ev35omcHW+mSTVpds5Oo2uzIvumYAM83oLzgWRnPLnx14l
+H+kWoDNaXEpUWDrmLD1A9OgNYmkeT/85qdyNjzDbe1yG421e2jUaRXez412Nv1s1
+ua5U/dBlAgMBAAECggEAFb2xkydLIjdA8C/Sx/gujXFWzGgyVV4sfVEB1KbYG/xi
+3FbQxfy6pIU2Qa4gXUvfjpo6bpf2DV+Ij2gsca4KOZY6qelJASIqHTijXOByy7vb
+ash5gVaMuJiRePxWCJ/BOw3KVTbB15ZiBh7ayvDJEhVUYo9rgB2Ff+KF3h3i4uMe
+cF6Z9aA0m9tYmEx88zk8kjxZMH7BhWX3C6H5FcGCy5ARhRQ4EoljDuIWD5+kgpMg
+0hnr2B+pP2AfA1p+Xowf56u17NyEK3QBfdrQp9GXfJaiiSVCqWR3foOqU59LiIjY
+TMp/7DgQqTPmLKifLPcZQcPtjU6dvBCl2NHZuPEBgQKBgQD5bi77KT3+UwePFPwy
+6lXoPAQryWpsQl8JpU3iFLZLx8uMUjtngDvQdU6D7XL5lB0b0gEOlIha2oedQT7F
+rhTm2Qiqj5pnxc8QU4xskSqudzF4WazmXdc2ajssiPWPpZtJj5FZPlw0WF/siFCR
+2gGCUcXwKe9vNv1Zn9N1KVNIBQKBgQDrX0aGY25YqIc6E9z/Y2KYJaH6rjf46p2Q
+RL8m6UiQheuRP2ePa1JLZQrDqXKp2ibZs+IyC1r+6ST7uCEDQjZcpxr+cLTp/ELY
+GtL17FXHjENfZm0JreeijJfUBZLqX0R6GTsi0kT0y8SQZiClpCZw51ovhIHLXrHG
+hWOVpAi04QKBgEshIQ2N0pp0L+atD3nWk6Gr0iXOOTv6kd256MecLXyN5YWSj0oR
+mfKkIs4iC2uZbVsf2immG5wiDo8TQ/EPCkSuQqn9Lyjqr//e6oEZCJ4cUM5LVITe
+5yAAx2oWpsBpxWhW0hTrb6JkrB/2vy3vWF0EfHZmazQ4f/8q4Op9VBRxAoGAKOKL
+5Zwv9saPds8sfFBPOA6RbHIG1v4qEH1glum+6RvaJ4jT/F2wFdifXg15FXgHd5l/
+mSHP1Ke6/N6nHWHK/50nWztIsbxYACHosz8yR09eBJxOJHhI3Dt/xByTwJJ72pm3
+Y/0SbVNX+Z1D3oH9C2+kgsyJn0H7r3hMLBoqSQECgYBTMbQ13v2B9U7I1UFryhYR
+ArfaO2VLseLWmEhTO/eSg8LJu9lDNRoagLl41qSRLNabxQzL/1bZTQb0i0S9wCL5
+tyx2YvLjN7wI0Pkd5WuZeT69ERroAFwASOINmzx96m83TxSe39c+OnxQbX4ujyQI
+iH1921Y1zSQbKsM7CeeFnw==
+-----END PRIVATE KEY-----
diff --git a/tests/settings.py b/tests/settings.py
index e0bd2f7..bf98fc7 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -1,5 +1,7 @@
 import os
 
+from authentic2.settings import MIDDLEWARE
+
 ALLOWED_HOSTS = ['localhost']
 
 DATABASES = {
@@ -15,8 +17,25 @@ if 'postgres' in DATABASES['default']['ENGINE']:
             DATABASES['default'][key[2:]] = os.environ[key]
 
 LANGUAGE_CODE = 'en'
-A2_FC_CLIENT_ID = ''
-A2_FC_CLIENT_SECRET = ''
+A2_AUTH_SAML_ENABLE = False
+A2_AUTH_FEDICT_ENABLE = True
+
+MELLON_ADAPTER = ["authentic2_auth_fedict.adapters.AuthenticAdapter"]
+MELLON_LOGIN_URL = "fedict-login"
+MELLON_PUBLIC_KEYS = ["./tests/saml.crt"]
+MELLON_PRIVATE_KEY = "./tests/saml.key"
+MELLON_IDENTITY_PROVIDERS = [
+    {
+        "METADATA": open("./tests/metadata.xml").read(),
+        "ENTITY_ID": "https://idp.com/",
+        "SLUG": "idp",
+    },
+]
+
+MELLON_ATTRIBUTE_MAPPING = {
+    "last_name": "{attributes[surname][0]}",
+    "first_name": "{attri,butes[givenName][0]}",
+}
 
 # test hook handlers
 A2_HOOKS_PROPAGATE_EXCEPTIONS = True
diff --git a/tests/test_all.py b/tests/test_all.py
index 7de9df8..ec93808 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -1,11 +1,284 @@
+import datetime
+
+import lasso
 import pytest
+from authentic2.a2_rbac.utils import get_default_ou
+from authentic2.models import Attribute
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
+from django.contrib.messages.middleware import MessageMiddleware
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.test.client import RequestFactory
+from mellon.models import Issuer, UserSAMLIdentifier
 from utils import login
 
+from authentic2_auth_fedict.adapters import AuthenticAdapter
+from authentic2_auth_fedict.backends import FedictBackend
+
 User = get_user_model()
 
 pytestmark = pytest.mark.django_db
 
+factory = RequestFactory()
+
+
+@pytest.fixture
+def adapter():
+    return AuthenticAdapter()
+
+
+@pytest.fixture
+def idp_conf():
+    return {
+        'A2_ATTRIBUTE_MAPPING': [
+            {
+                'attribute': 'email',
+                'saml_attribute': 'mail',
+            },
+            {
+                'attribute': 'last_name',
+                'saml_attribute': 'surname',
+            },
+            {
+                'attribute': 'first_name',
+                'saml_attribute': 'givenName',
+            },
+            {
+                'attribute': 'uuid',
+                'saml_attribute': 'urn:be:fedict:iam:attr:fedid',
+            },
+        ]
+    }
+
+
+@pytest.fixture
+def issuer():
+    return Issuer.objects.create(entity_id='https://idp.com/', slug='idp')
+
+
+@pytest.fixture
+def fedict_attributes():
+    return {
+        'issuer': 'https://idp.com/',
+        'name_id_content': 'c54db0a8ddc24a02a2d057f857d3b102',
+        'name_id_format': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,
+        'surname': ['Doe'],
+        'givenName': ['John'],
+        'mail': ['john.doe@example.org'],
+        'urn:be:fedict:iam:attr:fedid': ['c54db0a8ddc24a02a2d057f857d3b102'],
+    }
+
+
+def test_custom_lookup_user(adapter, idp_conf, issuer, fedict_attributes):
+    assert User.objects.count() == 0
+
+    user = adapter.lookup_user(idp_conf, fedict_attributes)
+    user.refresh_from_db()
+    assert user.email == ''  #  email purposely deleted
+    assert user.first_name == ''  #  not provisionned yet
+    assert user.last_name == ''  #  not provisionned yet
+    assert user.username == 'c54db0a8ddc24a02a2d057f857d3b102@saml'  #  <NameID>@<source>
+    assert user.ou == get_default_ou()
+
+
+def test_login_fedict_authenticator_displayed(app, settings, issuer):
+    response = app.get('/login/')
+    assert 'Belgian eID' in response
+    assert 'csam-login' in response
+
+
+def test_authenticate_eid_provision(app, settings, issuer):
+    backend = FedictBackend()
+    request = factory.get(path='/accounts/')
+    request.user = AnonymousUser()
+
+    saml_attributes = {
+        'city': [],
+        'zipcode': [],
+        'givenName': ['Doe'],
+        'surname': ['John'],
+        'verified_attributes': [],
+        'address': [],
+        'last_name': ['Doe'],
+        'first_name': ['John'],
+        'title': [],
+        'username': ['john.doe'],
+        'urn:be:fedict:iam:attr:fedid': ['xyz'],  #  new fedict id
+        'is_superuser': ['false'],
+        'phone': [],
+        'mobile': [],
+        'issuer': 'https://idp.com/',
+        'nonce': None,
+        'force_authn': False,
+        'name_id_content': 'xyz',  #  new fedict id
+        'name_id_format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
+        'name_id_name_qualifier': 'https://idp.com/idp/saml2/metadata',
+        'authn_instant': datetime.datetime(2022, 1, 27, 11, 26, 7, 301054),
+        'session_not_on_or_after': datetime.datetime(2022, 1, 27, 16, 26, 7),
+        'session_index': '_401EEEE3C700B6D203B83AA1826E3B80',
+        'authn_context_class_ref': 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
+        'name_id_content_orig': 'c54db0a8ddc24a02a2d057f857d3b102',
+    }
+    credentials = {'saml_attributes': saml_attributes}
+
+    assert len(User.objects.all()) == 0
+    SessionMiddleware().process_request(request)
+    MessageMiddleware().process_request(request)
+    backend_user = backend.authenticate(request, **credentials)
+    assert backend_user.is_authenticated
+    assert backend_user.username == 'xyz@saml'  #  <NameID>@<source>
+
+
+def test_authenticate_eid_link_to_existing_user(app, settings, issuer, user):
+    backend = FedictBackend()
+    request = factory.get(path='/accounts/')
+    request.user = user
+
+    UserSAMLIdentifier.objects.create(
+        user=user,
+        name_id='c54db0a8ddc24a02a2d057f857d3b102',
+        issuer=Issuer.objects.first(),
+    )
+
+    saml_attributes = {
+        'city': [],
+        'zipcode': [],
+        'givenName': ['Doe'],
+        'surname': ['John'],
+        'verified_attributes': [],
+        'address': [],
+        'last_name': ['Doe'],
+        'first_name': ['John'],
+        'title': [],
+        'username': ['john.doe'],
+        'urn:be:fedict:iam:attr:fedid': ['c54db0a8ddc24a02a2d057f857d3b102'],
+        'is_superuser': ['false'],
+        'phone': [],
+        'mobile': [],
+        'issuer': 'https://idp.com/',
+        'nonce': None,
+        'force_authn': False,
+        'name_id_content': 'c54db0a8ddc24a02a2d057f857d3b102',
+        'name_id_format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
+        'name_id_name_qualifier': 'https://idp.com/idp/saml2/metadata',
+        'authn_instant': datetime.datetime(2022, 1, 27, 11, 26, 7, 301054),
+        'session_not_on_or_after': datetime.datetime(2022, 1, 27, 16, 26, 7),
+        'session_index': '_401EEEE3C700B6D203B83AA1826E3B80',
+        'authn_context_class_ref': 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
+        'name_id_content_orig': 'c54db0a8ddc24a02a2d057f857d3b102',
+    }
+    credentials = {'saml_attributes': saml_attributes}
+
+    SessionMiddleware().process_request(request)
+    MessageMiddleware().process_request(request)
+    backend_user = backend.authenticate(request, **credentials)
+    assert backend_user == user
+
+
+def test_eid_unlink(app, settings, issuer, user):
+    assert len(UserSAMLIdentifier.objects.all()) == 0
+
+    # create link
+    UserSAMLIdentifier.objects.create(
+        user=user,
+        name_id='abc',
+        issuer=Issuer.objects.first(),
+    )
+
+    response = login(app, user, path='/accounts/', password=user.username)
+    assert "Unlink my account" in response.text
+    app.get('/accounts/fedict/unlink/').follow()
+
+    response = app.get('/accounts/')
+    assert "Link my account to my eID card" in response.text
+
+
+def test_provision_new_attributes_verified(app, settings, issuer, user):
+    Attribute.objects.create(name='title', kind='title', label='Titre')
+    # email & title verified
+    user.email = 'john.doe@verified.publik.love'
+    user.email_verified = True
+    user.verified_attributes.title = 'Mr'
+    user.first_name = 'Johnny'
+    user.last_name = 'Smith'
+    user.save()
+    UserSAMLIdentifier.objects.create(
+        user=user,
+        name_id='c54db0a8ddc24a02a2d057f857d3b102',
+        issuer=Issuer.objects.first(),
+    )
+
+    backend = FedictBackend()
+    request = factory.get(path='/accounts/')
+    request_user = User.objects.create(
+        first_name='Foo',
+        last_name='Bar',
+        email='foo.bar@nowhere.null',
+    )
+    request.user = request_user
+    saml_attributes = {
+        'givenName': ['Doe'],
+        'surname': ['John'],
+        'last_name': ['Doe'],
+        'first_name': ['John'],
+        'username': ['john.doe'],
+        'urn:be:fedict:iam:attr:fedid': ['c54db0a8ddc24a02a2d057f857d3b102'],
+        'is_superuser': ['false'],
+        'issuer': 'https://idp.com/',
+        'name_id_content': 'c54db0a8ddc24a02a2d057f857d3b102',
+        'name_id_format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
+        'name_id_name_qualifier': 'https://idp.com/idp/saml2/metadata',
+        'name_id_content_orig': 'c54db0a8ddc24a02a2d057f857d3b102',
+    }
+    credentials = {'saml_attributes': saml_attributes}
+    SessionMiddleware().process_request(request)
+    MessageMiddleware().process_request(request)
+    backend_user = backend.authenticate(request, **credentials)
+    assert backend_user == request_user
+    assert backend_user.email == 'john.doe@verified.publik.love'
+    assert backend_user.email_verified == True
+    assert backend_user.verified_attributes.title == 'Mr'
+    assert backend_user.first_name == 'Johnny'
+    assert backend_user.last_name == 'Smith'
+
+
+def test_provision_old_account_deleted(app, settings, issuer, user):
+    backend = FedictBackend()
+    request = factory.get(path='/accounts/')
+    request_user = User.objects.create(
+        first_name='foo',
+        last_name='bar',
+        email='foo.bar@nowhere.null',
+    )
+    request.user = request_user
+    count = User.objects.count()
+    user_uuid = user.uuid
+    UserSAMLIdentifier.objects.create(
+        user=user,
+        name_id='c54db0a8ddc24a02a2d057f857d3b102',
+        issuer=Issuer.objects.first(),
+    )
+    saml_attributes = {
+        'givenName': ['Doe'],
+        'surname': ['John'],
+        'last_name': ['Doe'],
+        'first_name': ['John'],
+        'username': ['john.doe'],
+        'urn:be:fedict:iam:attr:fedid': ['c54db0a8ddc24a02a2d057f857d3b102'],
+        'is_superuser': ['false'],
+        'issuer': 'https://idp.com/',
+        'name_id_content': 'c54db0a8ddc24a02a2d057f857d3b102',
+        'name_id_format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
+        'name_id_name_qualifier': 'https://idp.com/idp/saml2/metadata',
+        'name_id_content_orig': 'c54db0a8ddc24a02a2d057f857d3b102',
+    }
+    credentials = {'saml_attributes': saml_attributes}
 
-def test_dummy(app):
-    assert 1
+    SessionMiddleware().process_request(request)
+    MessageMiddleware().process_request(request)
+    backend_user = backend.authenticate(request, **credentials)
+    # link has been created on currently logged-in user
+    assert backend_user == request_user
+    # previous user as been deactivated
+    assert User.objects.count() == count - 1
+    assert not User.objects.filter(uuid=user_uuid)
diff --git a/tests/utils.py b/tests/utils.py
index f8fae5c..51c7fbf 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -9,7 +9,7 @@ def login(app, user, path=None, password=None, remember_me=None):
     else:
         login_page = app.get(reverse('auth_login'))
     assert login_page.request.path == reverse('auth_login')
-    form = login_page.form
+    form = login_page.forms[0]
     form.set('username', user.username if hasattr(user, 'username') else user)
     # password is supposed to be the same as username
     form.set('password', password or user.username)
-- 
2.34.1

