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
parent
c4c810cd25
commit
a57ab99014
|
@ -16,7 +16,9 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- .:/usr/src/app
|
- .:/usr/src/app
|
||||||
environment:
|
environment:
|
||||||
|
- TESTING=1
|
||||||
- TEMPLATES_AUTO_RELOAD=true
|
- TEMPLATES_AUTO_RELOAD=true
|
||||||
|
- AUTHLIB_INSECURE_TRANSPORT=1
|
||||||
- LDAP_BIND_DN
|
- LDAP_BIND_DN
|
||||||
- LDAP_BIND_PASSWORD
|
- LDAP_BIND_PASSWORD
|
||||||
- LOGGING_LEVEL=DEBUG
|
- LOGGING_LEVEL=DEBUG
|
||||||
|
|
|
@ -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 ###
|
|
@ -82,16 +82,20 @@ class LDAPUserProxy(object):
|
||||||
def email(self):
|
def email(self):
|
||||||
return self.username + "@hackerspace.pl"
|
return self.username + "@hackerspace.pl"
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def is_membership_active(self):
|
||||||
url = "https://kasownik.hackerspace.pl/api/judgement/{}.json"
|
url = "https://kasownik.hackerspace.pl/api/judgement/{}.json"
|
||||||
try:
|
try:
|
||||||
r = requests.get(url.format(self.username))
|
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:
|
except Exception as e:
|
||||||
logging.error("When getting data from Kasownik: {}".format(e))
|
logging.error("When getting data from Kasownik: {}".format(e))
|
||||||
# Fail-safe.
|
return False
|
||||||
return True
|
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
|
@ -80,3 +80,9 @@ class ClientForm(FlaskForm):
|
||||||
validators=[DataRequired()],
|
validators=[DataRequired()],
|
||||||
default=["openid"],
|
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",
|
||||||
|
)
|
||||||
|
|
|
@ -16,6 +16,10 @@ class Client(db.Model, OAuth2ClientMixin):
|
||||||
|
|
||||||
owner_id = db.Column(db.String(40), nullable=True)
|
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):
|
def __repr__(self):
|
||||||
return "<Client %s>" % (self.client_id,)
|
return "<Client %s>" % (self.client_id,)
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ WTF_CSRF_SSL_STRICT = env.bool("WTF_CSRF_SSL_STRICT", default=False)
|
||||||
|
|
||||||
SECRET_KEY = env.str("SECRET_KEY", default="randomstring")
|
SECRET_KEY = env.str("SECRET_KEY", default="randomstring")
|
||||||
|
|
||||||
|
TESTING = env.bool("TESTING", default=False)
|
||||||
|
|
||||||
db_username = env.str("DATABASE_USERNAME", default="postgres")
|
db_username = env.str("DATABASE_USERNAME", default="postgres")
|
||||||
db_password = env.str("DATABASE_PASSWORD", default="secret")
|
db_password = env.str("DATABASE_PASSWORD", default="secret")
|
||||||
db_hostname = env.str("DATABASE_HOSTNAME", default="postgres")
|
db_hostname = env.str("DATABASE_HOSTNAME", default="postgres")
|
||||||
|
|
44
sso/views.py
44
sso/views.py
|
@ -10,6 +10,7 @@ from flask import (
|
||||||
current_app,
|
current_app,
|
||||||
)
|
)
|
||||||
import uuid
|
import uuid
|
||||||
|
from functools import wraps
|
||||||
from flask_login import login_required, current_user, login_user, logout_user
|
from flask_login import login_required, current_user, login_user, logout_user
|
||||||
from sso.extensions import csrf
|
from sso.extensions import csrf
|
||||||
from sso.directory import LDAPUserProxy, check_credentials
|
from sso.directory import LDAPUserProxy, check_credentials
|
||||||
|
@ -25,6 +26,17 @@ from authlib.integrations.flask_oauth2 import current_token
|
||||||
bp = Blueprint("sso", __name__)
|
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("/")
|
||||||
@bp.route("/profile")
|
@bp.route("/profile")
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -75,6 +87,7 @@ def logout():
|
||||||
|
|
||||||
@bp.route("/client/create", methods=["GET", "POST"])
|
@bp.route("/client/create", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
@membership_required
|
||||||
def client_create():
|
def client_create():
|
||||||
form = ClientForm()
|
form = ClientForm()
|
||||||
|
|
||||||
|
@ -95,6 +108,7 @@ def client_create():
|
||||||
|
|
||||||
@bp.route("/client/<client_id>", methods=["GET", "POST"])
|
@bp.route("/client/<client_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
@membership_required
|
||||||
def client_edit(client_id):
|
def client_edit(client_id):
|
||||||
client = get_object_or_404(
|
client = get_object_or_404(
|
||||||
Client, Client.id == client_id, Client.owner_id == current_user.get_user_id()
|
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"])
|
@bp.route("/client/<client_id>/destroy", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@membership_required
|
||||||
def client_destroy(client_id):
|
def client_destroy(client_id):
|
||||||
client = get_object_or_404(
|
client = get_object_or_404(
|
||||||
Client, Client.id == client_id, Client.owner_id == current_user.get_user_id()
|
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"])
|
@bp.route("/client/<client_id>/regenerate", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@membership_required
|
||||||
def client_regenerate_secret(client_id):
|
def client_regenerate_secret(client_id):
|
||||||
client = get_object_or_404(
|
client = get_object_or_404(
|
||||||
Client, Client.id == client_id, Client.owner_id == current_user.get_user_id()
|
Client, Client.id == client_id, Client.owner_id == current_user.get_user_id()
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
print(request.form)
|
|
||||||
client.client_secret = generate_token()
|
client.client_secret = generate_token()
|
||||||
|
|
||||||
if request.form.get("revoke") == "yes":
|
if request.form.get("revoke") == "yes":
|
||||||
|
@ -151,12 +168,16 @@ def client_regenerate_secret(client_id):
|
||||||
@bp.route("/oauth/authorize", methods=["GET", "POST"])
|
@bp.route("/oauth/authorize", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def authorize():
|
def authorize():
|
||||||
if request.method == "GET":
|
try:
|
||||||
try:
|
grant = authorization.validate_consent_request(end_user=current_user)
|
||||||
grant = authorization.validate_consent_request(end_user=current_user)
|
except OAuth2Error as error:
|
||||||
except OAuth2Error as error:
|
return render_template("authorization_error.html", error=dict(error.get_body()))
|
||||||
return jsonify(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(
|
if Token.query.filter(
|
||||||
Token.client_id == grant.client.client_id,
|
Token.client_id == grant.client.client_id,
|
||||||
Token.user_id == current_user.get_user_id(),
|
Token.user_id == current_user.get_user_id(),
|
||||||
|
@ -193,6 +214,10 @@ def issue_token():
|
||||||
@require_oauth("profile:read openid", "OR")
|
@require_oauth("profile:read openid", "OR")
|
||||||
def api_profile():
|
def api_profile():
|
||||||
user = current_token.user
|
user = current_token.user
|
||||||
|
|
||||||
|
if current_token.client.membership_required and not user.is_membership_active:
|
||||||
|
abort(402)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
email=user.email,
|
email=user.email,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
@ -206,7 +231,12 @@ def api_profile():
|
||||||
@bp.route("/api/1/userinfo")
|
@bp.route("/api/1/userinfo")
|
||||||
@require_oauth("profile:read openid", "OR")
|
@require_oauth("profile:read openid", "OR")
|
||||||
def api_userinfo():
|
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")
|
@bp.route("/.well-known/openid-configuration")
|
||||||
|
|
|
@ -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="/">« Go back</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -25,6 +25,7 @@
|
||||||
{{ render_field(form.grant_types) }}
|
{{ render_field(form.grant_types) }}
|
||||||
{{ render_field(form.response_types) }}
|
{{ render_field(form.response_types) }}
|
||||||
{{ render_field(form.scope) }}
|
{{ render_field(form.scope) }}
|
||||||
|
{{ render_field(form.membership_required) }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-md-8 col-md-offset-4">
|
<div class="col-md-8 col-md-offset-4">
|
||||||
<button type="submit" class="btn btn-primary btn-block">Save</button>
|
<button type="submit" class="btn btn-primary btn-block">Save</button>
|
||||||
|
|
|
@ -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="/">« Go back</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue