summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPiotr Dobrowolski <admin@tastycode.pl>2020-05-25 21:57:04 +0200
committerPiotr Dobrowolski <admin@tastycode.pl>2020-05-25 21:57:04 +0200
commit54e1ba0608c08c0d4a575f19746bfd003eda2872 (patch)
treed96ebb411577db509ab86d071d0884d4edc8aa20
parentdcc698cbec8703f7c00b79e2b662530747f808b3 (diff)
downloadsso-v2-54e1ba0608c08c0d4a575f19746bfd003eda2872.tar.gz
sso-v2-54e1ba0608c08c0d4a575f19746bfd003eda2872.tar.bz2
sso-v2-54e1ba0608c08c0d4a575f19746bfd003eda2872.tar.xz
sso-v2-54e1ba0608c08c0d4a575f19746bfd003eda2872.zip
Add self-service client registration
-rw-r--r--migrations/versions/dd58bc95a904_add_client_owner_id.py28
-rw-r--r--requirements.txt1
-rw-r--r--sso/__init__.py12
-rw-r--r--sso/directory.py5
-rw-r--r--sso/forms.py50
-rw-r--r--sso/models.py2
-rw-r--r--sso/oauth2.py24
-rw-r--r--sso/utils.py11
-rw-r--r--sso/views.py60
-rw-r--r--static/css/authorize.css10
-rw-r--r--templates/_helpers.html105
-rw-r--r--templates/client_edit.html61
-rw-r--r--templates/profile.html17
13 files changed, 369 insertions, 17 deletions
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/<client_id>", 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 -%}
+<span class="amount{% if color %}{% if amount < 0 %} amount-negative{% else %} amount-positive{% endif %}{% endif %}" data-original="{{ amount }}">
+ {{ format_currency_raw(amount, precision) }}
+</span>
+{%- 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 %}
+ <div class="form-group{% if field.errors %} has-error{% endif %}">
+ {% if field.type == 'BooleanField' %}
+ <div class="col-md-{{ split }}"></div>
+ {% elif label %}
+ {{ field.label(class_='col-md-%s control-label' % (split,) + (' control-label-required' if field.flags.required else '')) }}
+ {% endif %}
+ <div class="col-md-{{ 12 - split }}">
+ {% endif %}
+
+ {{ render_field_inner(field, prefix, suffix, label=label, **kwargs) }}
+
+ {% if layout %}
+ </div>
+ </div>
+ {% endif %}
+ {% endif %}
+{% endmacro %}
+
+{% macro render_field_inner(field, prefix=None, suffix=None, label=True, input_group_class='') %}
+ {% if field.type == 'BooleanField' %}<div class="checkbox"><label for="{{ field.id }}">{% endif %}
+ {% if prefix or suffix %}<div class="input-group {{ input_group_class }}">{% endif %}
+ {% if prefix %}<span class="input-group-addon">{{ prefix }}</span>{% endif %}
+ {% if field.type == 'BooleanField' %}
+ {{ field(**kwargs) }} {% if label %}{{ field.label.text }}{% endif %}
+ {% elif field.type == 'RadioField' %}
+ {{ field(**kwargs) }}
+ {% elif field.type == 'MultiCheckboxField' %}
+ {{ field(**kwargs) }}
+ {% elif field.type == 'FieldList' %}
+ {% for f in field.entries %}
+ {{ render_field_inner(f) }}
+ {% endfor %}
+ {% else %}
+ {{ field(class_='form-control '+kwargs.pop('class_', ''), **kwargs) }}
+ {% endif %}
+ {% if suffix %}<span class="input-group-addon">{{ suffix }}</span>{% endif %}
+ {% if prefix or suffix %}</div>{% endif %}
+ {% if field.description and label %}
+ <span class="help-block">{{ field.description }}</span>
+ {% endif %}
+ {% if field.errors %}
+ {% for error in field.errors %}
+ <span class="help-block">{{ error }}</span>
+ {% endfor %}
+ {% endif %}
+ {% if field.type == 'BooleanField' %}</label></div>{% endif %}
+{% endmacro %}
+
+{% macro render_submit(label='Submit', class_='btn btn-primary', layout=True) %}
+{% if layout %}
+<div class="form-group">
+ <div class="col-md-9 col-md-offset-3">
+{% endif %}
+ <button type="submit" class="{{ class_ }}">{{ label }}</button>
+{% if layout %}
+ </div>
+</div>
+{% endif %}
+{% endmacro %}
+
+{% macro render_pagination(pagination) %}
+ <ul class="pagination text-center">
+ {% if pagination.has_prev %}
+ <li><a href="{{ url_for_other_page(pagination.page - 1) }}">&laquo;</a></li>
+ {% else %}
+ <li class="disabled"><a>&laquo;</a></li>
+ {% endif %}
+
+ {%- for page in pagination.iter_pages() %}
+ {% if page %}
+ {% if page != pagination.page %}
+ <li><a href="{{ url_for_other_page(page) }}">{{ page }}</a></li>
+ {% else %}
+ <li class="active"><a href="{{ url_for_other_page(page) }}">{{ page }}</a></li>
+ {% endif %}
+ {% else %}
+ <li class="disabled"><a>…</a></li>
+ {% endif %}
+ {%- endfor %}
+
+ {% if pagination.has_next %}
+ <li><a href="{{ url_for_other_page(pagination.page + 1) }}">&raquo;</a></li>
+ {% else %}
+ <li class="disabled"><a>&raquo;</a></li>
+ {% endif %}
+ </ul>
+{% 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) %}
+ <div class="form-group">
+ <label class="col-md-3 control-label control-label-required" for="{{ id }}">{{ label }}</label>
+ <div class="col-md-9">
+ <input class="form-control" id="{{ id }}" name="{{ id }}" required="" type="text" value="{{ value }}" readonly>
+ </div>
+ </div>
+{% endmacro %}
+{% block content %}
+ <div class="container">
+ <div class="col-md-8 col-md-offset-2">
+ <h2 class="page-header">
+ {% if client is defined %}Client edit{% else %}Client registration{% endif %}
+ </h2>
+ <form action="" class="form-horizontal" method="POST">
+ {{ 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) }}
+ <div class="form-group">
+ <div class="col-md-9 col-md-offset-3">
+ <button type="submit" class="btn btn-primary btn-block">Save</button>
+ </div>
+ </div>
+ {% if client is defined %}
+ {{ static_field('client_id', 'Client ID', client.client_id) }}
+ <div class="form-group">
+ <label class="col-md-3 control-label control-label-required" for="client_secret">Client secret</label>
+ <div class="col-md-9">
+ <div class="input-group">
+ <input class="form-control" id="client_secret" name="client_secret" required="" type="password" value="{{ client.client_secret }}" readonly>
+ <span class="input-group-btn">
+ <button class="btn btn-default" type="button" data-toggle="#client_secret"><i class="glyphicon glyphicon-eye-open"></i></button>
+ </span>
+ </div>
+ </div>
+ </div>
+ {{ 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 %}
+ </form>
+ </div>
+ </div>
+ <script>
+ document.querySelectorAll('button[data-toggle]').forEach(e => {
+ e.addEventListener('click', () => {
+ const input = document.querySelector(e.attributes['data-toggle'].value);
+ input.type = (input.type === 'password') ? 'text' : 'password';
+ });
+ });
+ </script>
+{% 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 %}
</tbody>
</table>
+
+ <h3>My applications <a href="{{ url_for('.client_create') }}" class="btn btn-primary btn-xs pull-right"><i class="glyphicon glyphicon-plus"></i> Register new application</a></h3>
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>Application name</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for client in clients %}
+ <tr><td>{{ client.client_name }}</td><td><a href="{{ url_for('.client_edit', client_id=client.id) }}" class="btn btn-xs btn-warning">Edit</a></td></tr>
+ {% else %}
+ <tr><td colspan=4 class="placeholder">No registered applications yet</td></tr>
+ {% endfor %}
+ </tbody>
+ </table>
</div>
</div>
{% endblock %}