diff --git a/migrations/versions/dd58bc95a904_add_client_owner_id.py b/migrations/versions/dd58bc95a904_add_client_owner_id.py new file mode 100644 index 0000000..4382435 --- /dev/null +++ b/migrations/versions/dd58bc95a904_add_client_owner_id.py @@ -0,0 +1,28 @@ +"""Add Client.owner_id + +Revision ID: dd58bc95a904 +Revises: 5d43eb9bfe78 +Create Date: 2020-05-25 17:55:05.506518 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dd58bc95a904' +down_revision = '5d43eb9bfe78' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('oauth2_client', sa.Column('owner_id', sa.String(length=40), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('oauth2_client', 'owner_id') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 397416c..f69d07a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ alembic==1.4.2 Authlib==0.14.3 +cached-property==1.5.1 certifi==2020.4.5.1 cffi==1.14.0 chardet==3.0.4 diff --git a/sso/__init__.py b/sso/__init__.py index 71b884b..8e2cef0 100644 --- a/sso/__init__.py +++ b/sso/__init__.py @@ -21,14 +21,14 @@ def create_app(): app.register_blueprint(sso.views.bp) from werkzeug.middleware.proxy_fix import ProxyFix - if app.config.get('PROXYFIX_ENABLE'): - print('gnuj') + + if app.config.get("PROXYFIX_ENABLE"): app.wsgi_app = ProxyFix( app.wsgi_app, - app.config.get('PROXYFIX_NUM_PROXIES'), - app.config.get('PROXYFIX_NUM_PROXIES'), - app.config.get('PROXYFIX_NUM_PROXIES'), - app.config.get('PROXYFIX_NUM_PROXIES'), + app.config.get("PROXYFIX_NUM_PROXIES"), + app.config.get("PROXYFIX_NUM_PROXIES"), + app.config.get("PROXYFIX_NUM_PROXIES"), + app.config.get("PROXYFIX_NUM_PROXIES"), ) return app diff --git a/sso/directory.py b/sso/directory.py index 06555f8..bd0d276 100644 --- a/sso/directory.py +++ b/sso/directory.py @@ -2,8 +2,8 @@ import re import requests import ldap import logging +from cached_property import cached_property from flask import current_app as app - from sso.extensions import login_manager @@ -47,7 +47,6 @@ class LDAPUserProxy(object): if len(res) != 1: raise Exception("No such username.") dn, data = res[0] - print(dn, data) self.username = data.get("uid", [b""])[0].decode() or None self.gecos = data.get("gecos", [b""])[0].decode() or None @@ -63,7 +62,7 @@ class LDAPUserProxy(object): def email(self): return self.username + "@hackerspace.pl" - # @cached_property + @cached_property def is_active(self): url = "https://kasownik.hackerspace.pl/api/judgement/{}.json" try: diff --git a/sso/forms.py b/sso/forms.py index c6b4e0f..6287d80 100644 --- a/sso/forms.py +++ b/sso/forms.py @@ -1,9 +1,55 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField -from wtforms.validators import DataRequired +from wtforms import ( + StringField, + PasswordField, + BooleanField, + SelectField, + SelectMultipleField, + FieldList, + widgets, +) +from wtforms.validators import DataRequired, URL + + +class MultiCheckboxField(SelectMultipleField): + """ + A multiple-select, except displays a list of checkboxes. + + Iterating the field will produce subfields, allowing custom rendering of + the enclosed checkbox fields. + """ + + widget = widgets.ListWidget(prefix_label=False) + option_widget = widgets.CheckboxInput() class LoginForm(FlaskForm): username = StringField("username", validators=[DataRequired()]) password = PasswordField("password", validators=[DataRequired()]) remember = BooleanField("remember me") + + +class ClientForm(FlaskForm): + client_name = StringField("Client name", validators=[DataRequired()]) + client_uri = StringField("Client URI", validators=[DataRequired(), URL()]) + redirect_uris = FieldList( + StringField("Redirect URI", validators=[DataRequired(), URL()]), min_entries=1 + ) + grant_types = MultiCheckboxField( + "Grant types", + choices=[("authorization_code", "authorization_code")], + validators=[DataRequired()], + default=["authorization_code"], + ) + response_types = MultiCheckboxField( + "Response types", + choices=[("code", "code")], + validators=[DataRequired()], + default=["code"], + ) + + token_endpoint_auth_method = SelectField( + "Token endpoint authentication method", + choices=[("client_secret_basic", "Basic"), ("client_secret_post", "POST")], + validators=[DataRequired()], + ) diff --git a/sso/models.py b/sso/models.py index dea7de3..5e5f4a9 100644 --- a/sso/models.py +++ b/sso/models.py @@ -12,6 +12,8 @@ class Client(db.Model, OAuth2ClientMixin): id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.String(40), nullable=True) + class AuthorizationCode(db.Model, OAuth2AuthorizationCodeMixin): __tablename__ = "oauth2_code" diff --git a/sso/oauth2.py b/sso/oauth2.py index cb1fb9b..b4e6a4a 100644 --- a/sso/oauth2.py +++ b/sso/oauth2.py @@ -113,10 +113,32 @@ class HybridGrant(_OpenIDHybridGrant): authorization = AuthorizationServer() require_oauth = ResourceProtector() +def save_token(token, request): + if request.user: + user_id = request.user.get_user_id() + else: + user_id = None + client = request.client + + # FIXME: is this the correct way of handling this? left for backward + # compatiblity + toks = Token.query.filter_by(client_id=client.client_id, + user_id=user_id) + + # make sure that every client has only one token connected to a user + for t in toks: + db.session.delete(t) + + item = Token( + client_id=client.client_id, + user_id=user_id, + **token + ) + db.session.add(item) + db.session.commit() def config_oauth(app): query_client = create_query_client_func(db.session, Client) - save_token = create_save_token_func(db.session, Token) authorization.init_app(app, query_client=query_client, save_token=save_token) # support all openid grants diff --git a/sso/utils.py b/sso/utils.py new file mode 100644 index 0000000..c054591 --- /dev/null +++ b/sso/utils.py @@ -0,0 +1,11 @@ +from sqlalchemy.orm import exc +from werkzeug.exceptions import abort + + +def get_object_or_404(model, *criterion): + try: + rv = model.query.filter(*criterion).one() + except (exc.NoResultFound, exc.MultipleResultsFound): + abort(404) + else: + return rv diff --git a/sso/views.py b/sso/views.py index 6891afd..e4c1b59 100644 --- a/sso/views.py +++ b/sso/views.py @@ -9,12 +9,15 @@ from flask import ( jsonify, current_app, ) +import uuid from flask_login import login_required, current_user, login_user, logout_user from sso.directory import LDAPUserProxy, check_credentials -from sso.models import db, Token -from sso.forms import LoginForm +from sso.models import db, Token, Client +from sso.forms import LoginForm, ClientForm +from sso.utils import get_object_or_404 from sso.oauth2 import authorization, require_oauth from authlib.oauth2 import OAuth2Error +from authlib.common.security import generate_token from authlib.integrations.flask_oauth2 import current_token @@ -27,7 +30,8 @@ bp = Blueprint("sso", __name__) def profile(): return render_template( "profile.html", - tokens=Token.query.filter(Token.user_id == current_user.username), + tokens=Token.query.filter(Token.user_id == current_user.get_user_id()), + clients=Client.query.filter(Client.owner_id == current_user.get_user_id()), ) @@ -68,6 +72,45 @@ def logout(): return redirect("/") +@bp.route("/client/create", methods=["GET", "POST"]) +@login_required +def client_create(): + form = ClientForm() + + if form.validate_on_submit(): + client = Client() + client.client_id = uuid.uuid4() + client.client_secret = generate_token() + client.owner_id = current_user.get_user_id() + client.set_client_metadata(form.data) + + db.session.add(client) + db.session.commit() + return redirect(url_for(".client_edit", client_id=client.id)) + + return render_template("client_edit.html", form=form) + + +@bp.route("/client/", methods=["GET", "POST"]) +@login_required +def client_edit(client_id): + client = get_object_or_404( + Client, Client.id == client_id, Client.owner_id == current_user.get_user_id() + ) + + form = ClientForm(obj=client) + + if form.validate_on_submit(): + client.set_client_metadata(form.data) + db.session.commit() + return redirect(url_for(".client_edit", client_id=client.id)) + + return render_template("client_edit.html", client=client, form=form) + + +# OAuth API + + @bp.route("/oauth/authorize", methods=["GET", "POST"]) @login_required def authorize(): @@ -76,6 +119,14 @@ def authorize(): grant = authorization.validate_consent_request(end_user=current_user) except OAuth2Error as error: return jsonify(dict(error.get_body())) + + if Token.query.filter( + Token.client_id == grant.client.client_id, + Token.user_id == current_user.get_user_id(), + ).count(): + # User has unrevoked token already - grant by default + return authorization.create_authorization_response(grant_user=current_user) + return render_template( "oauthorize.html", user=current_user, grant=grant, client=grant.client ) @@ -99,7 +150,6 @@ def issue_token(): @require_oauth("profile:read") def api_profile(): user = current_token.user - print(user.email, user.username, user.gecos, user.phone, user.personal_email) return jsonify( email=user.email, username=user.username, @@ -129,7 +179,7 @@ def api_userinfo(): def openid_configuration(): return jsonify( { - "issuer": current_app.config['JWT_CONFIG']['iss'], + "issuer": current_app.config["JWT_CONFIG"]["iss"], "authorization_endpoint": url_for(".authorize", _external=True), "token_endpoint": url_for(".issue_token", _external=True), "userinfo_endpoint": url_for(".api_userinfo", _external=True), diff --git a/static/css/authorize.css b/static/css/authorize.css index 6695764..f52c976 100644 --- a/static/css/authorize.css +++ b/static/css/authorize.css @@ -27,3 +27,13 @@ td.placeholder { font-style: italic; opacity: 0.5; } + +.form-group ul { + margin: 0; + padding: 0; + padding-top: 7px +} + +.form-group li { + list-style-type: none; +} diff --git a/templates/_helpers.html b/templates/_helpers.html new file mode 100644 index 0000000..2c4a648 --- /dev/null +++ b/templates/_helpers.html @@ -0,0 +1,105 @@ +{% macro format_currency(amount, color=True, precision=2) -%} +{%- if amount == None -%} +None +{%- else -%} + + {{ format_currency_raw(amount, precision) }} + +{%- endif %} +{%- endmacro %} + +{% macro format_currency_raw(amount, precision=0) -%} +{{ ("%%.%sf" | format(precision) | format(amount/100)) }}SOG +{%- endmacro %} +{% macro render_field(field, prefix=None, suffix=None, layout=True, label=True, split=3) %} + {% if field.type == 'HiddenField' or field.type == 'CSRFTokenField' %} + {{ field(**kwargs) }} + {% else %} + {% if layout %} +
+ {% if field.type == 'BooleanField' %} +
+ {% elif label %} + {{ field.label(class_='col-md-%s control-label' % (split,) + (' control-label-required' if field.flags.required else '')) }} + {% endif %} +
+ {% endif %} + + {{ render_field_inner(field, prefix, suffix, label=label, **kwargs) }} + + {% if layout %} +
+
+ {% endif %} + {% endif %} +{% endmacro %} + +{% macro render_field_inner(field, prefix=None, suffix=None, label=True, input_group_class='') %} + {% if field.type == 'BooleanField' %}
{% endif %} +{% endmacro %} + +{% macro render_submit(label='Submit', class_='btn btn-primary', layout=True) %} +{% if layout %} +
+
+{% endif %} + +{% if layout %} +
+
+{% endif %} +{% endmacro %} + +{% macro render_pagination(pagination) %} + +{% endmacro %} diff --git a/templates/client_edit.html b/templates/client_edit.html new file mode 100644 index 0000000..83970c2 --- /dev/null +++ b/templates/client_edit.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% from "_helpers.html" import render_field, render_submit %} + + +{% macro static_field(id, label, value) %} +
+ +
+ +
+
+{% endmacro %} +{% block content %} +
+
+ +
+ {{ form.csrf_token }} + {{ render_field(form.client_name) }} + {{ render_field(form.client_uri) }} + {{ render_field(form.redirect_uris) }} + {{ render_field(form.token_endpoint_auth_method) }} + {{ render_field(form.grant_types) }} + {{ render_field(form.response_types) }} +
+
+ +
+
+ {% if client is defined %} + {{ static_field('client_id', 'Client ID', client.client_id) }} +
+ +
+
+ + + + +
+
+
+ {{ static_field('openid_configuration', 'OpenID Connect Discovery Endpoint', url_for('.openid_configuration', _external=True)) }} + {{ static_field('token_endpoint', 'Token Endpoint', url_for('.issue_token', _external=True)) }} + {{ static_field('authorize_endpoint', 'Authorize Endpoint', url_for('.authorize', _external=True)) }} + {{ static_field('userinfo_endpoint', 'UserInfo Endpoint', url_for('.api_userinfo', _external=True)) }} + {% endif %} +
+
+
+ +{% endblock %} diff --git a/templates/profile.html b/templates/profile.html index 670f766..0e49c34 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -40,6 +40,23 @@ {% endfor %} + +

My applications Register new application

+ + + + + + + + + {% for client in clients %} + + {% else %} + + {% endfor %} + +
Application name
{{ client.client_name }}Edit
No registered applications yet
{% endblock %}