From a910d8084f3e59e8d93b3b16da97239552f79071 Mon Sep 17 00:00:00 2001
From: kaiyou <pierre@jaury.eu>
Date: Thu, 12 Sep 2019 21:45:16 +0200
Subject: [PATCH] Implement a very first naive saml idp

---
 requirements.txt                      |   2 +
 trurt/__init__.py                     |   3 +-
 trurt/sso/__init__.py                 |   6 ++
 trurt/sso/oidc.py                     |   0
 trurt/sso/saml.py                     | 117 ++++++++++++++++++++++++++
 trurt/sso/templates/sso_redirect.html |  23 +++++
 6 files changed, 150 insertions(+), 1 deletion(-)
 create mode 100644 trurt/sso/__init__.py
 create mode 100644 trurt/sso/oidc.py
 create mode 100644 trurt/sso/saml.py
 create mode 100644 trurt/sso/templates/sso_redirect.html

diff --git a/requirements.txt b/requirements.txt
index 506e3e39..5a6cbdbc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,3 +12,5 @@ passlib
 gunicorn
 PyYAML
 bcrypt
+pysaml2
+xmlsec
diff --git a/trurt/__init__.py b/trurt/__init__.py
index aa31c85c..1d4e78d7 100644
--- a/trurt/__init__.py
+++ b/trurt/__init__.py
@@ -30,8 +30,9 @@ def create_app_from_config(config):
         return dict(config=app.config)
 
     # Import views
-    from trurt import account
+    from trurt import account, sso
     app.register_blueprint(account.blueprint, url_prefix='/account')
+    app.register_blueprint(sso.blueprint, url_prefix='/sso')
 
     return app
 
diff --git a/trurt/sso/__init__.py b/trurt/sso/__init__.py
new file mode 100644
index 00000000..43c4d023
--- /dev/null
+++ b/trurt/sso/__init__.py
@@ -0,0 +1,6 @@
+from flask import Blueprint
+
+
+blueprint = Blueprint("sso", __name__, template_folder="templates")
+
+from trurt.sso import saml, oidc
diff --git a/trurt/sso/oidc.py b/trurt/sso/oidc.py
new file mode 100644
index 00000000..e69de29b
diff --git a/trurt/sso/saml.py b/trurt/sso/saml.py
new file mode 100644
index 00000000..ff8d3e32
--- /dev/null
+++ b/trurt/sso/saml.py
@@ -0,0 +1,117 @@
+from trurt.sso import blueprint
+from saml2 import sigver
+
+# We monkey-patch the security context factory, so that we can silently
+# replace it with our own xmlsec-based implementation.
+def security_context(conf):
+    return SecurityContext(conf)
+sigver.security_context = security_context
+
+from saml2 import server, saml, config, mdstore, assertion
+import saml2, base64, flask, xmlsec, lxml.etree, flask_login
+
+
+class MetaData(mdstore.InMemoryMetaData):
+    """ Implements dynamic metadata for a given service.
+    Metadata and configuration are generated per service, even general IDP
+    configuration, so that the IDP can use different keys for different SPs.
+    """
+
+    def __init__(self, attrc, entityid, **kwargs):
+        (super(MetaData, self).__init__)(attrc, **kwargs)
+        self.entityid = entityid
+
+    def load(self, *args, **kwargs):
+        """ Load the service metadata for asserion generation.
+        """
+        self.entity.update({self.entityid: {'spsso_descriptor': [
+            {'attribute_consuming_service': [{'requested_attribute': []}]}
+        ]}})
+
+    @classmethod
+    def get_config(cls, service_slug):
+        """ Load the IDP configuration.
+        """
+        idp_service = {
+            'endpoints':{},  'policy':{'default': {'lifetime':{'minutes': 15},
+            'attribute_restrictions':None,
+            'name_form':saml2.saml.NAME_FORMAT_URI,
+            'entity_categories':[]}},
+            'name_id_format':[saml2.saml.NAMEID_FORMAT_PERSISTENT]
+        }
+        config_dict = {
+            'key_file':'-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0X5UX/rQkpXRd0IIDqdqhJCew8Pes37Vroobqkt5mtdB6nFa\n5O8N+ZhS0iLWU7B3C0s14ZxrKeVcIoOlBsJuM88DHH+5679WAXEGG7hE7yBBhVxr\nhrMee0c4XSWQFEkh2UleeBKSKGMJpPvHvLJfwg9nf4wGkXsDiKFjPYGoQdhk+i/k\nmT43xgo8FpJES8QHwCfyDpb3hg0JPT8db0Dtc5D259mdjMYY2q7PsH3uCEww9VXN\nBsJAIdj2iiNlQGRwvHkG2+/LjdvvJPPQ1baFcM0KNSZvlc6oSe7oHR9yNL0DxcK8\nFIa7EEY0+8qEngE7zGIGDo2CfnFbnIbn9fiy2QIDAQABAoIBAQCLTM9aCvNJpWll\nPXkSFWyUvX10evfIrxvzNU50DD/OIDhqZfmkpPjL8OeRZyzQ9VQTJG2tmU8Aysxa\n/uJq/jo9JPfSqXO9OLs9tiPzprHft7kZrnypUs1/97mY5nNJqd9iFpFEkkSxqjkt\nhWYpKQrXhVqyyy9K6VtOLNJKgb6aGM7n0zTM3M8k1A8l5D/yEvnYYD/hTAbqQJ6y\nwUuNqNTnGTVR5iACt2rIvBKqSQtaQsANYlccbYeBGcIVyr5QXkcI0V2mLlMOKS6t\nrDr14DAs6d/2IT+OiD15S7HRb+Z9Y8e5eXbmSfasWDSz/SNrPwdSEY2URNi1r956\nXZDBXtYBAoGBAPv+3rVqvEBuaJd8Ss0VSndzfshE1ahk6QX2+AB89IcgWVfG9PJ7\nDz3R17qV3sDAfm15bn8BF8/3R3eMVmqbZb/R/HawXA6rrs1SxlXaFMb3cG//ZJxE\nxb+BwBSaiEj+CWBa02M85a7EuWH2dflDg22OOvA+1AW9JQ/SSIWUxUIhAoGBANTS\njxwf0tu78xBjgjY9GI1gtEC5k8S/Xio87/Tyde3l0jySK0fR7puWunccyQp13ANr\n6SiAvrqpzZjjkmvUC92CSCviDT09Ltqy8X9vaXQ8s3tAT/qhUTaBMDTAFi4tCttS\n9bx/ycO7LzOk0xLFT6bsjOeOdMLq0NYKKINCgMm5AoGAE9JJbFW39w14Nqo1LAqH\nr/uqtlALylIdrjVt7oPlrBdUT747mDMr0L4HzQpq2hiKGUxa76yDVf1qZrHoPjx4\n9WysAh3/L7w7ZLUlGq2rwrbF5lldbZlPQLARDs3U+IDa9fRO+lhY7LVWq6j6QKAZ\n3203n5whi04Ec0kkITXBimECgYEAtjP+aamlMJJcqm9HD4CHAKMGL1Ox+wOLbsX0\n+dSKuj3EHC9X9oj4qyQER+3RAK+eyR8d4ps2r0Co0Hgk50QHVIExoMBLbV5wOrRw\npRWRRv6g+qg40O5DRVKdHsxFMQtG/DauQ89zwasD4kb+nldmthZXG/eOZ0H5wQW5\nYYcSE6ECgYAfB5WGROqAr2KL0JPaGkNKBPtfaVJBkSGWz/kWsP4ih15XWTKB1HiH\nZ7sDgIcDUvAC7VOnnDvErt4AWlaoMormicovQQOMcO/K0dYJKtGgReGb4gwXm3je\n/wttmV+PDWQeaQXdv3oPpKtsgqNoca2S9EBZWUybdKVZ9YO9+kWfmQ==\n-----END RSA PRIVATE KEY-----',
+            'service':{'idp': idp_service},
+            'metadata':[
+                {'class':'trurt.sso.saml.MetaData',
+                 'metadata':[('https://status.kaiyou.fr/auth/auth/saml/callback', )]}
+            ]
+        }
+        return config.config_factory('idp', config_dict)
+
+
+class CertHandler(object):
+    """ Dummy implementation of a CertHandler that we can instanciate for
+    the security context.
+    """
+
+    def generate_cert(self):
+        return False
+
+
+class SecurityContext(sigver.SecurityContext):
+    """ Replacement for pysmal2 security context.
+
+    This implementation only provides stuff useful for an IDP. It is based on
+    Python integration of the xmlsec1 lib intead of the original exec-based
+    implementation.
+    """
+
+    def __init__(self, conf):
+        self.cert_handler = CertHandler()
+        self.conf = conf
+
+    def _check_signature(self, decoded_xml, item, node_name=None, origdoc=None, id_attr='', must=False, only_valid_cert=False, issuer=None):
+        # TODO: actually check the signature from authentication requests
+        # (not critical, but required for production)
+        return item
+
+    def sign_statement(self, statement, node_name, key=None, key_file=None, node_id=None, id_attr=''):
+        xml = lxml.etree.fromstring(statement)
+        # Specify the id attribute so that xmlsec can find the object referenced
+        # in the signature block.
+        xmlsec.tree.add_ids(xml, ['ID'])
+        signature = xmlsec.tree.find_node(xml, xmlsec.constants.NodeSignature)
+        context = xmlsec.SignatureContext()
+        context.key = xmlsec.Key.from_memory(
+            self.conf.getattr('key_file', ''),
+            xmlsec.constants.KeyDataFormatPem
+        )
+        context.sign(signature)
+        return lxml.etree.tostring(xml)
+
+
+@blueprint.route('/saml/<service_slug>/redirect')
+def auth(service_slug):
+    idp = server.Server(config=(MetaData.get_config(service_slug)))
+    request = idp.parse_authn_request(
+        flask.request.args['SAMLRequest'], saml2.BINDING_HTTP_REDIRECT
+    )
+    target = request.message.assertion_consumer_service_url
+    authn = assertion.authn_statement(saml2.saml.AUTHN_PASSWORD)
+    response = idp.create_authn_response(
+        identity={
+            'uid':'test12',
+            'email':'admintest12@tedomum.net'
+        },
+        in_response_to=(request.message.id),
+        destination=target,
+        sp_entity_id='https://status.kaiyou.fr/auth/auth/saml/callback',
+        userid=(flask_login.current_user.username),
+        authn={'class_ref': saml2.saml.AUTHN_PASSWORD},
+        sign_assertion=True
+    )
+    return flask.render_template('sso_redirect.html', target=target, data={
+        'SAMLResponse':base64.b64encode(response).decode('ascii'),
+        'RelayState':flask.request.args.get('RelayState', '')
+    })
diff --git a/trurt/sso/templates/sso_redirect.html b/trurt/sso/templates/sso_redirect.html
new file mode 100644
index 00000000..fd378f21
--- /dev/null
+++ b/trurt/sso/templates/sso_redirect.html
@@ -0,0 +1,23 @@
+<html>
+  <head>
+    <meta charset="utf-8" />
+  </head>
+  <body onloaed="document.forms[0].submit()">
+    <noscript>
+      <p>
+        <strong>Note:</strong>
+        Since your browser does not support JavaScript,
+        you must press the Continue button once to proceed.
+      </p>
+    </noscript>
+    <form action="{{ target }}" method="post">
+      {% for key, value in data.items() %}
+      <input type="hidden" name="{{ key }}" value="{{ value }}">
+      {% endfor %}
+      <noscript>
+        <input type="submit" value="Continue"/>
+      </noscript>
+      <input type="submit" value="Continue"/>
+    </form>
+  </body>
+</html>
-- 
GitLab