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" }} </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() }} </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