From a57ab990148f6337017cd2ccdf9ce38b0da99cc2 Mon Sep 17 00:00:00 2001 From: Piotr Dobrowolski Date: Fri, 29 Apr 2022 00:24:35 +0200 Subject: [PATCH] Implement membership_required client option User without an active membership: * will be able to log into sso.hackerspace.pl * will not be able to create/modify any of their clients * will not be able to authorize to any client that has "Active membership required" option enabled * API requests for said user will return 402 (in case a token has already been issued) Authorization errors will now be wrapped in properly rendered alerts. --- docker-compose.yml | 2 + ...41d00ea9_add_client_membership_required.py | 28 ++++++++++++ sso/directory.py | 12 +++-- sso/forms.py | 6 +++ sso/models.py | 4 ++ sso/settings.py | 2 + sso/views.py | 44 ++++++++++++++++--- templates/authorization_error.html | 12 +++++ templates/client_edit.html | 1 + templates/membership_required.html | 13 ++++++ 10 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 migrations/versions/59b941d00ea9_add_client_membership_required.py create mode 100644 templates/authorization_error.html create mode 100644 templates/membership_required.html diff --git a/docker-compose.yml b/docker-compose.yml index a249133..c6a225a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,9 @@ services: volumes: - .:/usr/src/app environment: + - TESTING=1 - TEMPLATES_AUTO_RELOAD=true + - AUTHLIB_INSECURE_TRANSPORT=1 - LDAP_BIND_DN - LDAP_BIND_PASSWORD - LOGGING_LEVEL=DEBUG diff --git a/migrations/versions/59b941d00ea9_add_client_membership_required.py b/migrations/versions/59b941d00ea9_add_client_membership_required.py new file mode 100644 index 0000000..8fb966d --- /dev/null +++ b/migrations/versions/59b941d00ea9_add_client_membership_required.py @@ -0,0 +1,28 @@ +"""add client.membership_required + +Revision ID: 59b941d00ea9 +Revises: dd58bc95a904 +Create Date: 2022-04-28 20:59:06.161062 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '59b941d00ea9' +down_revision = 'dd58bc95a904' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('oauth2_client', sa.Column('membership_required', sa.Boolean(), server_default='1', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('oauth2_client', 'membership_required') + # ### end Alembic commands ### diff --git a/sso/directory.py b/sso/directory.py index 25a259f..8053dbc 100644 --- a/sso/directory.py +++ b/sso/directory.py @@ -82,16 +82,20 @@ class LDAPUserProxy(object): def email(self): return self.username + "@hackerspace.pl" - @cached_property def is_active(self): + return True + + @cached_property + def is_membership_active(self): url = "https://kasownik.hackerspace.pl/api/judgement/{}.json" try: r = requests.get(url.format(self.username)) - return bool(r.json()["content"]) + r.raise_for_status() + data = r.json() + return data["status"] == "ok" and data["content"] except Exception as e: logging.error("When getting data from Kasownik: {}".format(e)) - # Fail-safe. - return True + return False def get_id(self): return self.username diff --git a/sso/forms.py b/sso/forms.py index 499d13a..22a022d 100644 --- a/sso/forms.py +++ b/sso/forms.py @@ -80,3 +80,9 @@ class ClientForm(FlaskForm): validators=[DataRequired()], default=["openid"], ) + + membership_required = BooleanField( + "Active membership required", + default=True, + description="User will be refused authorization to this client if their membership in Kasownik is not active", + ) diff --git a/sso/models.py b/sso/models.py index 9c9e8fa..f62278e 100644 --- a/sso/models.py +++ b/sso/models.py @@ -16,6 +16,10 @@ class Client(db.Model, OAuth2ClientMixin): owner_id = db.Column(db.String(40), nullable=True) + membership_required = db.Column( + db.Boolean, nullable=False, default=True, server_default="1" + ) + def __repr__(self): return "" % (self.client_id,) diff --git a/sso/settings.py b/sso/settings.py index 1f75b70..2e56f59 100644 --- a/sso/settings.py +++ b/sso/settings.py @@ -10,6 +10,8 @@ WTF_CSRF_SSL_STRICT = env.bool("WTF_CSRF_SSL_STRICT", default=False) SECRET_KEY = env.str("SECRET_KEY", default="randomstring") +TESTING = env.bool("TESTING", default=False) + db_username = env.str("DATABASE_USERNAME", default="postgres") db_password = env.str("DATABASE_PASSWORD", default="secret") db_hostname = env.str("DATABASE_HOSTNAME", default="postgres") diff --git a/sso/views.py b/sso/views.py index 10c3b43..343f24e 100644 --- a/sso/views.py +++ b/sso/views.py @@ -10,6 +10,7 @@ from flask import ( current_app, ) import uuid +from functools import wraps from flask_login import login_required, current_user, login_user, logout_user from sso.extensions import csrf from sso.directory import LDAPUserProxy, check_credentials @@ -25,6 +26,17 @@ from authlib.integrations.flask_oauth2 import current_token bp = Blueprint("sso", __name__) +def membership_required(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + print(current_user.is_membership_active) + if current_user.is_anonymous or not current_user.is_membership_active: + return render_template("membership_required.html") + return fn(*args, **kwargs) + + return wrapped + + @bp.route("/") @bp.route("/profile") @login_required @@ -75,6 +87,7 @@ def logout(): @bp.route("/client/create", methods=["GET", "POST"]) @login_required +@membership_required def client_create(): form = ClientForm() @@ -95,6 +108,7 @@ def client_create(): @bp.route("/client/", methods=["GET", "POST"]) @login_required +@membership_required def client_edit(client_id): client = get_object_or_404( Client, Client.id == client_id, Client.owner_id == current_user.get_user_id() @@ -112,6 +126,8 @@ def client_edit(client_id): @bp.route("/client//destroy", methods=["GET", "POST"]) +@login_required +@membership_required def client_destroy(client_id): client = get_object_or_404( Client, Client.id == client_id, Client.owner_id == current_user.get_user_id() @@ -128,13 +144,14 @@ def client_destroy(client_id): @bp.route("/client//regenerate", methods=["GET", "POST"]) +@login_required +@membership_required def client_regenerate_secret(client_id): client = get_object_or_404( Client, Client.id == client_id, Client.owner_id == current_user.get_user_id() ) if request.method == "POST": - print(request.form) client.client_secret = generate_token() if request.form.get("revoke") == "yes": @@ -151,12 +168,16 @@ def client_regenerate_secret(client_id): @bp.route("/oauth/authorize", methods=["GET", "POST"]) @login_required def authorize(): - if request.method == "GET": - try: - grant = authorization.validate_consent_request(end_user=current_user) - except OAuth2Error as error: - return jsonify(dict(error.get_body())) + try: + grant = authorization.validate_consent_request(end_user=current_user) + except OAuth2Error as error: + return render_template("authorization_error.html", error=dict(error.get_body())) + print(grant) + if grant.client.membership_required and not current_user.is_membership_active: + return render_template("membership_required.html") + + if request.method == "GET": if Token.query.filter( Token.client_id == grant.client.client_id, Token.user_id == current_user.get_user_id(), @@ -193,6 +214,10 @@ def issue_token(): @require_oauth("profile:read openid", "OR") def api_profile(): user = current_token.user + + if current_token.client.membership_required and not user.is_membership_active: + abort(402) + return jsonify( email=user.email, username=user.username, @@ -206,7 +231,12 @@ def api_profile(): @bp.route("/api/1/userinfo") @require_oauth("profile:read openid", "OR") def api_userinfo(): - return jsonify(generate_user_info(current_token.user, current_token.scope)) + user = current_token.user + + if current_token.client.membership_required and not user.is_membership_active: + abort(402) + + return jsonify(generate_user_info(user, current_token.scope)) @bp.route("/.well-known/openid-configuration") diff --git a/templates/authorization_error.html b/templates/authorization_error.html new file mode 100644 index 0000000..6dba999 --- /dev/null +++ b/templates/authorization_error.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% from "_helpers.html" import csrf_field %} + +{% block content %} +
+
+ + « Go back +
+{% endblock %} diff --git a/templates/client_edit.html b/templates/client_edit.html index 7eac5d9..4265ec1 100644 --- a/templates/client_edit.html +++ b/templates/client_edit.html @@ -25,6 +25,7 @@ {{ render_field(form.grant_types) }} {{ render_field(form.response_types) }} {{ render_field(form.scope) }} + {{ render_field(form.membership_required) }}
diff --git a/templates/membership_required.html b/templates/membership_required.html new file mode 100644 index 0000000..e029a33 --- /dev/null +++ b/templates/membership_required.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% from "_helpers.html" import csrf_field %} + +{% block content %} +
+
+ + « Go back +
+{% endblock %}