From e81eaa58e179ebe2f4f0cc46494081dca8207073 Mon Sep 17 00:00:00 2001
From: kaiyou <pierre@jaury.eu>
Date: Thu, 12 Sep 2019 23:42:36 +0200
Subject: [PATCH] Add a simple profile picker

---
 migrations/versions/546912a5c987_.py  | 26 ++++++++++
 trurt/__init__.py                     |  3 +-
 trurt/account/login.py                |  2 +-
 trurt/manage.py                       | 16 ++++++
 trurt/models.py                       | 15 ++++++
 trurt/service/__init__.py             |  4 ++
 trurt/sso/__init__.py                 |  2 +-
 trurt/sso/forms.py                    |  9 ++++
 trurt/sso/profile.py                  | 19 +++++++
 trurt/sso/saml.py                     | 72 +++++++++++++++++----------
 trurt/sso/templates/sso_pick.html     | 15 ++++++
 trurt/sso/templates/sso_redirect.html |  3 +-
 12 files changed, 155 insertions(+), 31 deletions(-)
 create mode 100644 migrations/versions/546912a5c987_.py
 create mode 100644 trurt/service/__init__.py
 create mode 100644 trurt/sso/forms.py
 create mode 100644 trurt/sso/profile.py
 create mode 100644 trurt/sso/templates/sso_pick.html

diff --git a/migrations/versions/546912a5c987_.py b/migrations/versions/546912a5c987_.py
new file mode 100644
index 00000000..80e798d1
--- /dev/null
+++ b/migrations/versions/546912a5c987_.py
@@ -0,0 +1,26 @@
+""" Add a uuid per profile
+
+Revision ID: 546912a5c987
+Revises: a95b3a78f983
+Create Date: 2019-09-12 22:32:19.352747
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = '546912a5c987'
+down_revision = 'a95b3a78f983'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    with op.batch_alter_table('profile') as batch:
+        batch.add_column(sa.Column('uuid', sa.String(length=36)))
+        batch.create_unique_constraint('profile_unique', ['uuid'])
+
+
+def downgrade():
+    with op.batch_alter_table('profile') as batch:
+        batch.drop_constraint('profile_unique', type_='unique')
+        batch.drop_column('uuid')
diff --git a/trurt/__init__.py b/trurt/__init__.py
index 1d4e78d7..00c229a8 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, sso
+    from trurt import account, service, sso
     app.register_blueprint(account.blueprint, url_prefix='/account')
+    app.register_blueprint(service.blueprint, url_prefix='/service')
     app.register_blueprint(sso.blueprint, url_prefix='/sso')
 
     return app
diff --git a/trurt/account/login.py b/trurt/account/login.py
index 6a2af4a5..dd4609ba 100644
--- a/trurt/account/login.py
+++ b/trurt/account/login.py
@@ -13,7 +13,7 @@ def login():
         if user:
             flask_login.login_user(user)
             endpoint = flask.request.args.get("next", "/")
-            return flask.redirect(flask.url_for(endpoint))
+            return flask.redirect(flask.url_for(endpoint, **flask.request.args))
         else:
             flask.flash("Wrong credentials")
     return flask.render_template("account_login.html", form=form)
diff --git a/trurt/manage.py b/trurt/manage.py
index 56c31848..87aac60c 100644
--- a/trurt/manage.py
+++ b/trurt/manage.py
@@ -26,3 +26,19 @@ def create_user(username, password):
     models.db.session.add(user)
     models.db.session.add(auth)
     models.db.session.commit()
+
+
+@trurt.command()
+@click.argument("username")
+@click.argument("spn")
+@click.argument("profile_username")
+@flask_cli.with_appcontext
+def create_profile(username, spn, profile_username):
+    user = models.User.query.filter_by(username=username).first()
+    service = models.Service.query.filter_by(spn=spn).first()
+    profile = models.Profile()
+    profile.user = user
+    profile.service = service
+    profile.username = profile_username
+    models.db.session.add(profile)
+    models.db.session.commit()
diff --git a/trurt/models.py b/trurt/models.py
index af1dca26..c03b7042 100644
--- a/trurt/models.py
+++ b/trurt/models.py
@@ -5,6 +5,8 @@ from datetime import date
 
 import flask_sqlalchemy
 import sqlalchemy
+import json
+import uuid
 
 
 class Base(flask_sqlalchemy.Model):
@@ -69,6 +71,14 @@ class User(db.Model):
     def get_id(self):
         return self.id
 
+    def get_default_profile(self, service):
+        profile = Profile()
+        profile.service = service
+        profile.user = self
+        profile.username = self.username
+        profile.uuid = self.username
+        return profile
+
 
 class Auth(db.Model):
     """ An authenticator is a method to authenticate a user.
@@ -112,4 +122,9 @@ class Profile(db.Model):
     service = db.relationship(Service,
         backref=db.backref('profiles', cascade='all, delete-orphan'))
 
+    uuid = db.Column(db.String(36), unique=True, default=lambda: str(uuid.uuid4()))
     username = db.Column(db.String(255), nullable=False)
+
+    @property
+    def email(self):
+        return self.uuid + "@kaiyou.fr"
diff --git a/trurt/service/__init__.py b/trurt/service/__init__.py
new file mode 100644
index 00000000..485de474
--- /dev/null
+++ b/trurt/service/__init__.py
@@ -0,0 +1,4 @@
+from flask import Blueprint
+
+
+blueprint = Blueprint("service", __name__, template_folder="templates")
diff --git a/trurt/sso/__init__.py b/trurt/sso/__init__.py
index 43c4d023..77564400 100644
--- a/trurt/sso/__init__.py
+++ b/trurt/sso/__init__.py
@@ -3,4 +3,4 @@ from flask import Blueprint
 
 blueprint = Blueprint("sso", __name__, template_folder="templates")
 
-from trurt.sso import saml, oidc
+from trurt.sso import saml, oidc, profile
diff --git a/trurt/sso/forms.py b/trurt/sso/forms.py
new file mode 100644
index 00000000..368414e9
--- /dev/null
+++ b/trurt/sso/forms.py
@@ -0,0 +1,9 @@
+from wtforms import validators, fields, widgets
+from flask_babel import lazy_gettext as _
+
+import flask_wtf
+
+
+class SSOValidateForm(flask_wtf.FlaskForm):
+    service_id = fields.IntegerField('service', [])
+    profile_id = fields.IntegerField('profile', [])
diff --git a/trurt/sso/profile.py b/trurt/sso/profile.py
new file mode 100644
index 00000000..6e4ae48d
--- /dev/null
+++ b/trurt/sso/profile.py
@@ -0,0 +1,19 @@
+from trurt.sso import blueprint, forms
+from trurt import models
+
+import flask_login
+import flask
+
+
+@blueprint.route("/pick/<service_spn>/<return_endpoint>")
+@flask_login.login_required
+def pick(service_spn, return_endpoint):
+    service = models.Service.query.filter_by(spn=service_spn).first_or_404()
+    profiles = models.Profile.query.filter_by(
+        service_id=service.id,
+        user_id=flask_login.current_user.id
+    )
+    form = forms.SSOValidateForm()
+    return flask.render_template("sso_pick.html",
+        service=service, profiles=profiles, form=form,
+        return_endpoint=return_endpoint, args=flask.request.args)
diff --git a/trurt/sso/saml.py b/trurt/sso/saml.py
index ff8d3e32..c71ffa69 100644
--- a/trurt/sso/saml.py
+++ b/trurt/sso/saml.py
@@ -1,12 +1,12 @@
-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)
+from saml2 import sigver
 sigver.security_context = security_context
 
+from trurt.sso import blueprint, forms
+from trurt import models
 from saml2 import server, saml, config, mdstore, assertion
 import saml2, base64, flask, xmlsec, lxml.etree, flask_login
 
@@ -29,22 +29,23 @@ class MetaData(mdstore.InMemoryMetaData):
         ]}})
 
     @classmethod
-    def get_config(cls, service_slug):
+    def get_config(cls, service):
         """ Load the IDP configuration.
         """
         idp_service = {
             'endpoints':{},  'policy':{'default': {'lifetime':{'minutes': 15},
-            'attribute_restrictions':None,
-            'name_form':saml2.saml.NAME_FORMAT_URI,
+            'attribute_restrictions': None,
+            'name_form': saml2.saml.NAME_FORMAT_URI,
             'entity_categories':[]}},
-            'name_id_format':[saml2.saml.NAMEID_FORMAT_PERSISTENT]
+            '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-----',
+            'key_file': service.config["idp_key"],
+            'cert_file': service.config["sp_cert"],
             'service':{'idp': idp_service},
             'metadata':[
                 {'class':'trurt.sso.saml.MetaData',
-                 'metadata':[('https://status.kaiyou.fr/auth/auth/saml/callback', )]}
+                 'metadata':[(service.config["entityid"], )]}
             ]
         }
         return config.config_factory('idp', config_dict)
@@ -84,34 +85,53 @@ class SecurityContext(sigver.SecurityContext):
         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
+            base64.b64decode(self.conf.getattr('key_file', '')),
+            xmlsec.constants.KeyDataFormatDer
         )
         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)
+@blueprint.route('/saml/<service_spn>/redirect')
+def redirect(service_spn):
+    service = models.Service.query.filter_by(spn=service_spn).first_or_404()
+    return flask.redirect(flask.url_for(
+        "sso.pick", service_spn=service_spn,
+        return_endpoint="sso.reply",
+        **flask.request.args
+    ))
+
+
+@blueprint.route('/saml/reply', methods=["POST"])
+def reply():
+    # First check the service and picked profile
+    form = forms.SSOValidateForm()
+    if not form.validate():
+        return flask.abort(403)
+    service = models.Service.query.get(form.service_id.data)
+    profile = models.Profile.query.get(form.profile_id.data)
+    if not (profile.service_id == service.id and profile.user_id == flask_login.current_user.id):
+        return flask.abort(403)
+    # Parse the authentication request
+    idp = server.Server(config=(MetaData.get_config(service)))
+    xml = flask.request.args["SAMLRequest"]
+    request = idp.parse_authn_request(xml, saml2.BINDING_HTTP_REDIRECT)
+    if not service.config["acs"] == request.message.issuer.text:
+        return flask.abort(403)
+    # Provide a SAML response
     response = idp.create_authn_response(
         identity={
-            'uid':'test12',
-            'email':'admintest12@tedomum.net'
+            'uid': profile.username,
+            'email': profile.email
         },
-        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),
+        in_response_to=request.message.id,
+        destination=service.config["acs"],
+        sp_entity_id=service.config["entityid"],
+        userid=profile.username,
         authn={'class_ref': saml2.saml.AUTHN_PASSWORD},
         sign_assertion=True
     )
-    return flask.render_template('sso_redirect.html', target=target, data={
+    return flask.render_template('sso_redirect.html', target=service.config["acs"], data={
         'SAMLResponse':base64.b64encode(response).decode('ascii'),
         'RelayState':flask.request.args.get('RelayState', '')
     })
diff --git a/trurt/sso/templates/sso_pick.html b/trurt/sso/templates/sso_pick.html
new file mode 100644
index 00000000..6410c674
--- /dev/null
+++ b/trurt/sso/templates/sso_pick.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block title %}Pick a profile{% endblock %}
+{% block subtitle %}for the service {{ service.spn }}{% endblock %}
+
+{% block content %}
+{% for profile in profiles %}
+<form method="POST" action="{{ url_for(return_endpoint, **args) }}">
+    {{ form.hidden_tag() }}
+    <input type="hidden" name="service_id" value="{{ service.id }}">
+    <input type="hidden" name="profile_id" value="{{ profile.id }}">
+    <input type="submit" value="{{ profile.username }}">
+</form>
+{% endfor %}
+{% endblock %}
diff --git a/trurt/sso/templates/sso_redirect.html b/trurt/sso/templates/sso_redirect.html
index fd378f21..e9ebbf9f 100644
--- a/trurt/sso/templates/sso_redirect.html
+++ b/trurt/sso/templates/sso_redirect.html
@@ -2,7 +2,7 @@
   <head>
     <meta charset="utf-8" />
   </head>
-  <body onloaed="document.forms[0].submit()">
+  <body onload="document.forms[0].submit()">
     <noscript>
       <p>
         <strong>Note:</strong>
@@ -17,7 +17,6 @@
       <noscript>
         <input type="submit" value="Continue"/>
       </noscript>
-      <input type="submit" value="Continue"/>
     </form>
   </body>
 </html>
-- 
GitLab