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