From 67c682c92ebd7a34dd7aa51cecb7e68bc7126313 Mon Sep 17 00:00:00 2001
From: kaiyou <pierre@jaury.eu>
Date: Sat, 5 Oct 2019 17:04:15 +0200
Subject: [PATCH] Add the ability to create a service

---
 requirements.txt                              |  1 +
 trurt/__init__.py                             |  4 +-
 trurt/account/forms.py                        |  4 +
 trurt/account/profiles.py                     | 13 ++-
 trurt/account/templates/account_pick.html     |  5 +-
 trurt/admin/__init__.py                       |  4 -
 trurt/models.py                               | 23 ++++-
 trurt/service/__init__.py                     |  6 ++
 trurt/service/admin.py                        | 43 ++++++++
 trurt/service/forms.py                        | 19 ++++
 trurt/service/templates/service_create.html   | 25 +++++
 .../templates/service_create_form.html        |  8 ++
 trurt/service/templates/service_details.html  | 28 ++++++
 trurt/service/templates/service_list.html     | 31 ++++++
 trurt/sso/__init__.py                         |  5 +
 trurt/sso/forms.py                            |  6 +-
 trurt/sso/saml.py                             | 99 ++++++++++++++-----
 trurt/sso/templates/protocol_oidc.html        |  2 +
 trurt/sso/templates/protocol_saml.html        | 28 ++++++
 trurt/templates/sidebar.html                  |  8 ++
 trurt/utils.py                                |  7 ++
 21 files changed, 333 insertions(+), 36 deletions(-)
 delete mode 100644 trurt/admin/__init__.py
 create mode 100644 trurt/service/__init__.py
 create mode 100644 trurt/service/admin.py
 create mode 100644 trurt/service/forms.py
 create mode 100644 trurt/service/templates/service_create.html
 create mode 100644 trurt/service/templates/service_create_form.html
 create mode 100644 trurt/service/templates/service_details.html
 create mode 100644 trurt/service/templates/service_list.html
 create mode 100644 trurt/sso/templates/protocol_oidc.html
 create mode 100644 trurt/sso/templates/protocol_saml.html

diff --git a/requirements.txt b/requirements.txt
index 5a6cbdbc..eabb6f94 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,3 +14,4 @@ PyYAML
 bcrypt
 pysaml2
 xmlsec
+cryptography
diff --git a/trurt/__init__.py b/trurt/__init__.py
index 4c292bde..6cf2d5cd 100644
--- a/trurt/__init__.py
+++ b/trurt/__init__.py
@@ -30,10 +30,10 @@ def create_app_from_config(config):
         return dict(config=app.config)
 
     # Import views
-    from trurt import account, admin, 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')
-    app.register_blueprint(admin.blueprint, url_prefix='/admin')
 
     @app.route("/")
     def index():
diff --git a/trurt/account/forms.py b/trurt/account/forms.py
index 998e8977..c6a3d729 100644
--- a/trurt/account/forms.py
+++ b/trurt/account/forms.py
@@ -34,3 +34,7 @@ class ProfileForm(flask_wtf.FlaskForm):
     username = fields.StringField(_('Username'), [validators.DataRequired()])
     comment = fields.StringField(_('Comment'))
     submit = fields.SubmitField(_('Create profile'))
+
+
+class ProfilePickForm(flask_wtf.FlaskForm):
+    profile_uuid = fields.TextField('profile', [])
diff --git a/trurt/account/profiles.py b/trurt/account/profiles.py
index 0dc92dd1..f7cda945 100644
--- a/trurt/account/profiles.py
+++ b/trurt/account/profiles.py
@@ -6,6 +6,17 @@ import flask_login
 import flask
 
 
+def pick_profile(service, **redirect_args):
+    form = forms.ProfilePickForm()
+    if form.validate_on_submit():
+        profile = models.Profile.query.get(form.profile_uuid.data)
+        if not (profile.user == flask_login.current_user and
+                profile.service == service):
+            return None
+        return profile
+    utils.force_redirect(utils.url_for("account.pick", **redirect_args))
+
+
 @blueprint.route("/profiles")
 def profiles():
     return flask.render_template("account_profiles.html")
@@ -20,7 +31,7 @@ def pick():
         service_uuid=service.uuid,
         user_uuid=flask_login.current_user.uuid
     ).all()
-    form = sso_forms.SSOValidateForm()
+    form = forms.ProfilePickForm()
     return flask.render_template("account_pick.html",
         service=service, profiles=profiles, form=form,
         action_create=utils.url_for("account.create_profile", intent="account.pick"),
diff --git a/trurt/account/templates/account_pick.html b/trurt/account/templates/account_pick.html
index 5acfaa66..4f6e8164 100644
--- a/trurt/account/templates/account_pick.html
+++ b/trurt/account/templates/account_pick.html
@@ -30,7 +30,7 @@
         <form method="POST" action="{{ action_pick }}" class="form">
             {{ form.hidden_tag() }}
             <input type="hidden" name="profile_uuid" value="{{ profile.uuid }}">
-            <input type="submit" value="Use this" class="btn btn-lg btn-bg-gray text-black pull-right">
+            <input type="submit" value="Use this" style="opacity: 0.8" class="btn btn-lg btn-flat bg-gray text-black pull-right">
         </form>
         <h3 class="widget-header-username">{{ profile.username }}</h3>
         <h5 class="widget-header-desc">{{ profile.comment or "No profile description" }}&nbsp;</h5>
@@ -41,9 +41,8 @@
           <li><a href="#">Not shared with anyone</a></li>
         </ul>
       </div>
-
     </div>
   </div>
-
 {% endfor %}
+</div>
 {% endblock %}
diff --git a/trurt/admin/__init__.py b/trurt/admin/__init__.py
deleted file mode 100644
index 6e21f720..00000000
--- a/trurt/admin/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from flask import Blueprint
-
-
-blueprint = Blueprint("admin", __name__, template_folder="templates")
diff --git a/trurt/models.py b/trurt/models.py
index 013692f4..020d2b33 100644
--- a/trurt/models.py
+++ b/trurt/models.py
@@ -133,9 +133,31 @@ class Service(db.Model):
     """
     __tablename__ = "service"
 
+    APPLICATIONS = {
+        "generic": "Generic application",
+        "mastodon": "Mastodon"
+    }
+
+    LOCKED = "locked"
+    RESERVED = "reserved"
+    MANAGED = "managed"
+    BURST = "burst"
+    OPEN = "open"
+
+    POLICIES = {
+        LOCKED: "Profile creation is impossible",
+        RESERVED: "Profile creation is reserved to managers",
+        MANAGED: "Profile creation must be validated",
+        BURST: "Additional profiles must be validated",
+        OPEN: "No validation is required"
+    }
+
     protocol = db.Column(db.String(25))
     name = db.Column(db.String(255))
+    provider = db.Column(db.String(255))
+    application = db.Column(db.String(255))
     description = db.Column(db.String())
+    policy = db.Column(db.String(255))
     max_profiles = db.Column(db.Integer(), nullable=False, default=1)
     config = db.Column(JSONEncoded)
 
@@ -199,5 +221,4 @@ class History(db.Model):
 
     @property
     def description(self):
-        print(History.DESCRIPTION.get(self.category, "").format(this=self))
         return History.DESCRIPTION.get(self.category, "").format(this=self)
diff --git a/trurt/service/__init__.py b/trurt/service/__init__.py
new file mode 100644
index 00000000..342c163f
--- /dev/null
+++ b/trurt/service/__init__.py
@@ -0,0 +1,6 @@
+from flask import Blueprint
+
+
+blueprint = Blueprint("service", __name__, template_folder="templates")
+
+from trurt.service import admin
diff --git a/trurt/service/admin.py b/trurt/service/admin.py
new file mode 100644
index 00000000..5ad5610a
--- /dev/null
+++ b/trurt/service/admin.py
@@ -0,0 +1,43 @@
+from trurt import models, utils
+from trurt.service import blueprint, forms
+from trurt.sso import protocols
+
+import flask_login
+import flask
+import uuid
+
+
+@blueprint.route("/list")
+def list():
+    services = models.Service.query.all()
+    return flask.render_template("service_list.html", services=services)
+
+
+@blueprint.route("/create")
+def create():
+    return flask.render_template("service_create.html", protocols=protocols)
+
+
+@blueprint.route("/create/<protocol_name>", methods=["GET", "POST"])
+def create_protocol(protocol_name):
+    protocol = protocols.get(protocol_name, None) or flask.abort(404)
+    form = protocol.Config.derive_form(forms.ServiceForm)()
+    if form.validate_on_submit():
+        service = models.Service()
+        service.protocol = protocol_name
+        service.uuid = str(uuid.uuid4())
+        service.config = {}
+        form.populate_obj(service)
+        protocol.Config.populate_service(form, service)
+        models.db.session.add(service)
+        models.db.session.commit()
+        flask.flash("Service successfully created", "success")
+        return flask.redirect(flask.url_for(".list"))
+    return flask.render_template("service_create_form.html",
+        protocol=protocol, form=form)
+
+
+@blueprint.route("/details/<service_uuid>")
+def details(service_uuid):
+    service = models.Service.query.get(service_uuid) or flask.abort(404)
+    return flask.render_template("service_details.html", service=service)
diff --git a/trurt/service/forms.py b/trurt/service/forms.py
new file mode 100644
index 00000000..485df48a
--- /dev/null
+++ b/trurt/service/forms.py
@@ -0,0 +1,19 @@
+from wtforms import validators, fields, widgets
+from flask_babel import lazy_gettext as _
+
+from trurt import models
+
+import flask_wtf
+
+
+class ServiceForm(flask_wtf.FlaskForm):
+    name = fields.StringField(_('Service name'), [validators.DataRequired()])
+    provider = fields.StringField(_('Provider'), [validators.DataRequired()])
+    description = fields.StringField(_('Description'))
+    application = fields.SelectField(_('Application'),
+        choices=list(models.Service.APPLICATIONS.items()))
+    policy = fields.SelectField(_('Profile policy'),
+        choices=list(models.Service.POLICIES.items()))
+    max_profiles = fields.IntegerField(_('Maximum profile count'),
+        [validators.NumberRange(0, 1000)])
+    submit = fields.SubmitField(_('Submit'))
diff --git a/trurt/service/templates/service_create.html b/trurt/service/templates/service_create.html
new file mode 100644
index 00000000..372bff5d
--- /dev/null
+++ b/trurt/service/templates/service_create.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block title %}Create a service{% endblock %}
+{% block subtitle %}pick a protocol{% endblock %}
+
+{% set colors = ['blue', 'green', 'orange', 'teal', 'red', 'purple', 'maroon'] %}
+
+{% block content %}
+<div class="row">
+{% for protocol in protocols %}
+{% import "protocol_" + protocol + ".html" as protocol_macros %}
+<div class="col-md-4 col-s-6 col-xs-12">
+  <div class="box box-widget widget-user-2">
+    <div class="widget-user-header bg-{{ colors[loop.index0 % 7] }}">
+      <a href="{{ url_for(".create_protocol", protocol_name=protocol) }}" style="opacity: 0.8" class="btn btn-lg bg-gray text-black pull-right">
+        Create {{ protocol_macros.name() }}
+      </a>
+      <h3 class="widget-header-username">{{ protocol_macros.name() }}</h3>
+      <h5 class="widget-header-desc">{{ protocol_macros.description() }}&nbsp;</h5>
+    </div>
+  </div>
+</div>
+{% endfor %}
+</div>
+{% endblock %}
diff --git a/trurt/service/templates/service_create_form.html b/trurt/service/templates/service_create_form.html
new file mode 100644
index 00000000..bbd7e4f4
--- /dev/null
+++ b/trurt/service/templates/service_create_form.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+
+{% block title %}Create a service{% endblock %}
+{% block subtitle %}add a SAML service{% endblock %}
+
+{% block content %}
+{{ macros.form(form) }}
+{% endblock %}
diff --git a/trurt/service/templates/service_details.html b/trurt/service/templates/service_details.html
new file mode 100644
index 00000000..bcfe6cdd
--- /dev/null
+++ b/trurt/service/templates/service_details.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+{% import "protocol_" + service.protocol + ".html" as protocol_macros %}
+
+{% block title %}{{ service.name }}{% endblock %}
+{% block subtitle %}service details{% endblock %}
+
+{% block content %}
+<div class="row">
+  <div class="col-12">
+    <div class="box box-solid">
+      <div class="box-body">
+        <dl class="dl-horizontal">
+          <dt>Service name</dt>
+          <dd>{{ service.name }}</dd>
+
+          <dt>Description</dt>
+          <dd>{{ service.description }}</dd>
+
+          <dt>UUID</dt>
+          <dd>{{ service.uuid }}</dd>
+
+          {{ protocol_macros.describe(service) }}
+        </dl>
+      </div>
+      </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/trurt/service/templates/service_list.html b/trurt/service/templates/service_list.html
new file mode 100644
index 00000000..9f42f126
--- /dev/null
+++ b/trurt/service/templates/service_list.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+
+{% block title %}Service list{% endblock %}
+{% block subtitle %}all available services{% endblock %}
+
+{% block content %}
+<div class="row">
+  <div class="col-xs-12">
+    <div class="box">
+      <div class="box-body table-responsive no-padding">
+        <table class="table table-hover">
+          <tr>
+            <th>Service</th>
+            <th>Provider</th>
+            <th>Policy</th>
+            <th>Max profiles</th>
+          </tr>
+          {% for service in services %}
+          <tr>
+            <td><a href="{{ url_for(".details", service_uuid=service.uuid) }}">{{ service.name }}</a></td>
+            <td>{{ service.provider }}</td>
+            <td>{{ service.policy }}</td>
+            <td>{{ service.max_profiles }}</td>
+          </tr>
+          {% endfor %}
+        </table>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/trurt/sso/__init__.py b/trurt/sso/__init__.py
index 43c4d023..76e28a7a 100644
--- a/trurt/sso/__init__.py
+++ b/trurt/sso/__init__.py
@@ -4,3 +4,8 @@ from flask import Blueprint
 blueprint = Blueprint("sso", __name__, template_folder="templates")
 
 from trurt.sso import saml, oidc
+
+protocols = {
+    "saml": saml,
+    "oidc": oidc
+}
diff --git a/trurt/sso/forms.py b/trurt/sso/forms.py
index 7eba34bd..b9baddae 100644
--- a/trurt/sso/forms.py
+++ b/trurt/sso/forms.py
@@ -4,5 +4,7 @@ from flask_babel import lazy_gettext as _
 import flask_wtf
 
 
-class SSOValidateForm(flask_wtf.FlaskForm):
-    profile_uuid = fields.TextField('profile', [])
+class SAMLForm(flask_wtf.FlaskForm):
+    entityid = fields.StringField('SP entity id', [validators.URL()])
+    acs = fields.StringField('SP ACS', [validators.URL()])
+    submit = fields.SubmitField('Submit')
diff --git a/trurt/sso/saml.py b/trurt/sso/saml.py
index fa0529b2..0cd3abf9 100644
--- a/trurt/sso/saml.py
+++ b/trurt/sso/saml.py
@@ -6,9 +6,71 @@ from saml2 import sigver
 sigver.security_context = security_context
 
 from trurt.sso import blueprint, forms
-from trurt import models, utils
+from trurt import models, utils, account
 from saml2 import server, saml, config, mdstore, assertion
-import saml2, base64, flask, xmlsec, lxml.etree, flask_login
+from cryptography import x509
+from cryptography.hazmat import primitives, backends
+
+import saml2, base64, datetime, flask, xmlsec, lxml.etree, flask_login
+
+
+class Config(object):
+    """ Handles service configuration and forms.
+    """
+
+    @classmethod
+    def derive_form(cls, form):
+        """ Add required fields to a form.
+        """
+        return type('NewForm', (form, forms.SAMLForm), {})
+
+    @classmethod
+    def populate_service(cls, form, service):
+        """ Populate a service from a form
+        """
+        service.config.update({
+            "acs": form.acs.data,
+            "entityid": form.entityid.data
+        })
+        cls.update_keys(service)
+
+    @classmethod
+    def populate_form(cls, service, form):
+        """ Populate a form from a service
+        """
+        form.acs.data = service.config["acs"]
+        form.entityid.data = service.config["entityid"]
+
+    @classmethod
+    def update_keys(cls, service):
+        if "idp_cert" not in service.config:
+            key, cert = cls.generate_key(service.uuid + "-idp")
+            service.config.update({"idp_key": key, "idp_cert": cert})
+        if "sp_cert" not in service.config:
+            key, cert = cls.generate_key(service.uuid + "-sp")
+            service.config.update({"sp_key": key, "sp_cert": cert})
+
+    @classmethod
+    def generate_key(cls, cn):
+        key = primitives.asymmetric.rsa.generate_private_key(
+            key_size=2048, public_exponent=65535,
+            backend=backends.default_backend()
+        )
+        now = datetime.datetime.utcnow()
+        subject = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, cn)])
+        cert = x509.CertificateBuilder().subject_name(subject)\
+            .issuer_name(subject).serial_number(x509.random_serial_number())\
+            .public_key(key.public_key())\
+            .not_valid_before(now)\
+            .not_valid_after(now + datetime.timedelta(days=3650))\
+            .sign(key, primitives.hashes.SHA256(), backends.default_backend())
+        return (
+            key.private_bytes(primitives.serialization.Encoding.PEM,
+                format=primitives.serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=primitives.serialization.NoEncryption()
+            ).decode("ascii"),
+            cert.public_bytes(primitives.serialization.Encoding.PEM).decode("ascii")
+        )
 
 
 class MetaData(mdstore.InMemoryMetaData):
@@ -40,8 +102,8 @@ class MetaData(mdstore.InMemoryMetaData):
             'name_id_format': [saml2.saml.NAMEID_FORMAT_PERSISTENT]
         }
         config_dict = {
-            'key_file': service.config["idp_key"],
-            'cert_file': service.config["sp_cert"],
+            'key_file': "".join(service.config["idp_key"].strip().split("\n")[1:-1]),
+            'cert_file': "".join(service.config["sp_cert"].strip().split("\n")[1:-1]),
             'service':{'idp': idp_service},
             'metadata':[
                 {'class':'trurt.sso.saml.MetaData',
@@ -92,29 +154,20 @@ class SecurityContext(sigver.SecurityContext):
         return lxml.etree.tostring(xml)
 
 
-@blueprint.route('/saml/<service_uuid>/redirect')
-def redirect(service_uuid):
-    service = models.Service.query.get(service_uuid) or flask.abort(404)
-    return flask.redirect(utils.url_for(
-        "account.pick", intent="sso.reply", service_uuid=service_uuid,
-    ))
-
-
-@blueprint.route('/saml/<service_uuid>/reply', methods=["POST"])
-def reply(service_uuid):
-    # First check the service and picked profile
-    form = forms.SSOValidateForm()
-    form.validate() or flask.abort(403)
+@blueprint.route('/saml/<service_uuid>', methods=["GET", "POST"])
+def saml_redirect(service_uuid):
+    # Get the profile from user input (implies redirects)
     service = models.Service.query.get(service_uuid) or flask.abort(404)
-    profile = models.Profile.query.get(form.profile_uuid.data) or flask.abort(404)
-    if not (profile.user == flask_login.current_user and profile.service == service):
-        return flask.abort(403)
-    # Parse the authentication request
+    service.protocol == "saml" or flask.abort(404)
+    profile = account.profiles.pick_profile(
+        service, intent="sso.saml_redirect", service_uuid=service_uuid
+    ) or flask.abort(403)
+    # Parse the authentication request and check the ACS
     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)
+    request.message.issuer or flask.abort(403)
+    service.config["acs"] == request.message.issuer.text or flask.abort(403)
     # Provide a SAML response
     response = idp.create_authn_response(
         identity={
diff --git a/trurt/sso/templates/protocol_oidc.html b/trurt/sso/templates/protocol_oidc.html
new file mode 100644
index 00000000..b8a4ee75
--- /dev/null
+++ b/trurt/sso/templates/protocol_oidc.html
@@ -0,0 +1,2 @@
+{% macro name() %}OIDC{% endmacro %}
+{% macro description() %}OpenID Connect (OIDC) is JWT based authentication and authorization protocol{% endmacro %}
diff --git a/trurt/sso/templates/protocol_saml.html b/trurt/sso/templates/protocol_saml.html
new file mode 100644
index 00000000..3506f011
--- /dev/null
+++ b/trurt/sso/templates/protocol_saml.html
@@ -0,0 +1,28 @@
+{% macro name() %}SAML2{% endmacro %}
+{% macro description() %}SAML2 is a legacy protocol based on XML security. Only redirect/post binding is supported.{% endmacro %}
+
+{% macro describe(service) %}
+<dt>Endpoint</dt>
+<dd>{{ url_for("sso.saml_redirect", service_uuid=service.uuid, _external=True) }}</dd>
+
+<dt>ACS</dt>
+<dd>{{ service.config["acs"] }}</dd>
+
+<dt>IDP certificate</dt>
+<dd><pre>{{ service.config["idp_cert"] }}</pre></dd>
+
+<dt>Short IDP certificate</dt>
+<dd><pre>{{ "".join(service.config["idp_cert"].strip().split("\n")[1:-1]) }}</pre></dd>
+
+<dt>SP certificate</dt>
+<dd><pre>{{ service.config["sp_cert"] }}</pre></dd>
+
+<dt>Short SP certificate</dt>
+<dd><pre>{{ "".join(service.config["sp_cert"].strip().split("\n")[1:-1]) }}</pre></dd>
+
+<dt>SP private key</dt>
+<dd><pre>{{ service.config["sp_key"] }}</pre></dd>
+
+<dt>Short SP private key</dt>
+<dd><pre>{{ "".join(service.config["sp_key"].strip().split("\n")[1:-1]) }}</pre></dd>
+{% endmacro %}
diff --git a/trurt/templates/sidebar.html b/trurt/templates/sidebar.html
index fe9f8116..8118b16d 100644
--- a/trurt/templates/sidebar.html
+++ b/trurt/templates/sidebar.html
@@ -28,6 +28,14 @@
 </li>
 {% endif %}
 
+<li class="header">Management</li>
+<li>
+  <a href="{{ url_for("service.list") }}">
+    <i class="fa fa-book"></i> <span>Services</span>
+  </a>
+</li>
+
+
 <li class="header">About</li>
 <li>
   <a href="#">
diff --git a/trurt/utils.py b/trurt/utils.py
index 53a05a5f..2cb79642 100644
--- a/trurt/utils.py
+++ b/trurt/utils.py
@@ -5,6 +5,7 @@ import flask_babel
 import flask_limiter
 
 from werkzeug.contrib import fixers
+from werkzeug import routing
 
 
 # Login configuration
@@ -47,6 +48,12 @@ def url_or_intent(endpoint):
         return flask.url_for(endpoint)
 
 
+def force_redirect(destination):
+    """ Force a redirect by triggering an exception
+    """
+    raise routing.RequestRedirect(destination)
+
+
 # Request rate limitation
 limiter = flask_limiter.Limiter(key_func=lambda: current_user.id)
 
-- 
GitLab