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.
master
informatic 2022-04-29 00:24:35 +02:00
parent c4c810cd25
commit a57ab99014
10 changed files with 113 additions and 11 deletions

View File

@ -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

View File

@ -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 ###

View File

@ -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

View File

@ -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",
)

View File

@ -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 "<Client %s>" % (self.client_id,)

View File

@ -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")

View File

@ -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/<client_id>", 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/<client_id>/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/<client_id>/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")

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% from "_helpers.html" import csrf_field %}
{% block content %}
<div class="container" id="authorize-container">
<center><img src="/static/hswaw_wht.svg" style="width: 50%;"/></center>
<div class="alert alert-danger" role="alert">
<p><strong>Oh snap!</strong> {{ error.error }}: {{ error.error_description }}</p>
</div>
<a href="/">&laquo; Go back</a>
</div>
{% endblock %}

View File

@ -25,6 +25,7 @@
{{ render_field(form.grant_types) }}
{{ render_field(form.response_types) }}
{{ render_field(form.scope) }}
{{ render_field(form.membership_required) }}
<div class="form-group">
<div class="col-md-8 col-md-offset-4">
<button type="submit" class="btn btn-primary btn-block">Save</button>

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% from "_helpers.html" import csrf_field %}
{% block content %}
<div class="container" id="authorize-container">
<center><img src="/static/hswaw_wht.svg" style="width: 50%;"/></center>
<div class="alert alert-danger" role="alert">
<p><strong>Oh snap!</strong> Your membership has expired. This operation requires active membership.</p>
<p>Go to <a href="https://kasownik.hackerspace.pl">Kasownik</a> and check your pending membership fees.</p>
</div>
<a href="/">&laquo; Go back</a>
</div>
{% endblock %}