From 69aa2b3d119274087208b13063e09f7ef1c6a0b4 Mon Sep 17 00:00:00 2001 From: Sergiusz Bazanski Date: Mon, 24 Feb 2020 19:46:38 +0100 Subject: [PATCH] wip --- app.py | 3 + auth.py | 403 -------------------------------------- bin/application_add.py | 34 ++-- requirements.txt | 26 +++ templates/oauthorize.html | 13 +- templates/profile.html | 2 +- website/__init__.py | 0 website/app.py | 33 ++++ website/forms.py | 9 + website/ldap.py | 85 ++++++++ website/models.py | 142 ++++++++++++++ website/oauth2.py | 138 +++++++++++++ website/routes.py | 133 +++++++++++++ 13 files changed, 595 insertions(+), 426 deletions(-) create mode 100644 app.py delete mode 100644 auth.py create mode 100644 requirements.txt create mode 100644 website/__init__.py create mode 100644 website/app.py create mode 100644 website/forms.py create mode 100644 website/ldap.py create mode 100644 website/models.py create mode 100644 website/oauth2.py create mode 100644 website/routes.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..8cabea6 --- /dev/null +++ b/app.py @@ -0,0 +1,3 @@ +from website.app import create_app + +app = create_app() diff --git a/auth.py b/auth.py deleted file mode 100644 index 8a17793..0000000 --- a/auth.py +++ /dev/null @@ -1,403 +0,0 @@ -import ldap -import logging -import re -from datetime import datetime, timedelta - - -from cached_property import cached_property -import flask -from flask import Flask, render_template, make_response, flash, redirect, url_for -from flask_oauthlib.provider import OAuth2Provider -from flask_login import ( - LoginManager, - login_user, - logout_user, - login_required, - current_user, -) -from flask_sqlalchemy import SQLAlchemy -from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField -from wtforms.validators import DataRequired -import requests - - -logging.basicConfig( - level=logging.DEBUG, - format="[%(asctime)-15s] %(name)-10s %(levelname)7s: %(message)s", -) -app = Flask("auth") -app.config.from_object(__name__) -app.config.from_pyfile("auth.cfg") -oauth = OAuth2Provider(app) -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_view = "/login" -db = SQLAlchemy(app) - - -class Client(db.Model): - # human readable name - name = db.Column(db.String(40)) - # human readable description - description = db.Column(db.String(400)) - - client_id = db.Column(db.String(40), primary_key=True) - client_secret = db.Column(db.String(55), unique=True, index=True, nullable=False) - - # public or confidential - is_confidential = db.Column(db.Boolean) - - redirect_uris_ = db.Column(db.Text) - default_scopes_ = db.Column(db.Text) - - # TODO - # approved = db.Column(db.Boolean, default=False) - approved = True - - @property - def client_type(self): - if self.is_confidential: - return "confidential" - return "public" - - @property - def redirect_uris(self): - if self.redirect_uris_: - return self.redirect_uris_.split() - return [] - - @property - def default_redirect_uri(self): - return self.redirect_uris[0] - - @property - def default_scopes(self): - if self.default_scopes_: - return self.default_scopes_.split() - return [] - - def validate_scopes(self, scopes): - return { - "profile:read", - "profile:write", - "password:write", - "users:read", - }.issuperset(scopes) - - -class Grant(db.Model): - id = db.Column(db.Integer, primary_key=True) - - user = db.Column(db.String(40), nullable=False) - - client_id = db.Column( - db.String(40), db.ForeignKey("client.client_id"), nullable=False - ) - client = db.relationship("Client") - - code = db.Column(db.String(255), index=True, nullable=False) - - redirect_uri = db.Column(db.String(255)) - expires = db.Column(db.DateTime) - - _scopes = db.Column(db.Text) - - def delete(self): - db.session.delete(self) - db.session.commit() - return self - - @property - def scopes(self): - if self._scopes: - return self._scopes.split() - return [] - - -class Token(db.Model): - id = db.Column(db.Integer, primary_key=True) - client_id = db.Column( - db.String(40), db.ForeignKey("client.client_id"), nullable=False - ) - client = db.relationship("Client") - - user = db.Column(db.String(40), nullable=False) - - # currently only bearer is supported - token_type = db.Column(db.String(40)) - - access_token = db.Column(db.String(255), unique=True) - refresh_token = db.Column(db.String(255), unique=True) - expires = db.Column(db.DateTime) - _scopes = db.Column(db.Text) - - def delete(self): - db.session.delete(self) - db.session.commit() - return self - - @property - def scopes(self): - if self._scopes: - return self._scopes.split() - return [] - - -def connect_to_ldap(): - conn = ldap.initialize(app.config["LDAP_URL"]) - conn.start_tls_s() - conn.simple_bind(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PASSWORD"]) - return conn - - -def check_credentials(username, password): - conn = ldap.initialize(app.config["LDAP_URL"]) - conn.start_tls_s() - try: - conn.simple_bind_s(app.config["DN_STRING"] % username, password) - return True - except ldap.LDAPError: - return False - - -@oauth.clientgetter -def load_client(client_id): - return Client.query.filter_by(client_id=client_id).first() - - -@oauth.grantgetter -def load_grant(client_id, code): - return Grant.query.filter_by(client_id=client_id, code=code).first() - - -@oauth.grantsetter -def save_grant(client_id, code, request, *args, **kwargs): - # decide the expires time yourself - expires = datetime.utcnow() + timedelta(seconds=100) - grant = Grant( - client_id=client_id, - code=code["code"], - redirect_uri=request.redirect_uri, - _scopes=" ".join(request.scopes), - user=current_user.username, - expires=expires, - ) - db.session.add(grant) - db.session.commit() - return grant - - -@oauth.tokengetter -def load_token(access_token=None, refresh_token=None): - if access_token: - return Token.query.filter_by(access_token=access_token).first() - elif refresh_token: - return Token.query.filter_by(refresh_token=refresh_token).first() - - -@oauth.tokensetter -def save_token(token, request, *args, **kwargs): - toks = Token.query.filter_by(client_id=request.client.client_id, user=request.user) - # make sure that every client has only one token connected to a user - for t in toks: - db.session.delete(t) - - expires_in = token.get("expires_in") - expires = datetime.utcnow() + timedelta(seconds=expires_in) - - tok = Token( - access_token=token["access_token"], - refresh_token=token.get("refresh_token"), - token_type=token["token_type"], - _scopes=token["scope"], - expires=expires, - client_id=request.client.client_id, - user=request.user, - ) - db.session.add(tok) - db.session.commit() - return tok - - -@app.route("/oauth/authorize", methods=["GET", "POST"]) -@login_required -@oauth.authorize_handler -def authorize(*args, **kwargs): - form = FlaskForm() - - if Token.query.filter( - Token.client_id == kwargs.get("client_id"), Token.user == current_user.username - ).count(): - # User has unrevoked token already - grant by default - return True - - if not form.validate_on_submit(): - client_id = kwargs.get("client_id") - client = Client.query.filter_by(client_id=client_id).first() - kwargs["client"] = client - kwargs["user"] = current_user - kwargs["form"] = form - return render_template("oauthorize.html", **kwargs) - - confirm = flask.request.form.get("confirm", "no") - return confirm == "yes" - - -@app.route("/oauth/token", methods=["GET", "POST"]) -@oauth.token_handler -def access_token(): - return None - - -class LDAPUserProxy(object): - def __init__(self, username): - self.username = re.sub(app.config["STRIP_RE"], "", username) - self.is_authenticated = True - self.is_anonymous = False - conn = connect_to_ldap() - res = conn.search_s( - app.config["PEOPLE_BASEDN"], - ldap.SCOPE_SUBTREE, - app.config["UID_LDAP_FILTER"] % self.username, - ) - if len(res) != 1: - raise Exception("No such username.") - dn, data = res[0] - - self.username = data.get("uid", [None])[0] - self.gecos = data.get("gecos", [None])[0] - self.mifare_hashes = data.get("mifareIDHash", []) - self.phone = data.get("mobile", [None])[0] - self.personal_email = data.get("mailRoutingAddress", []) - - def __repr__(self): - active = "active" if self.is_active else "inactive" - return "".format(self.username, active) - - @property - def email(self): - return self.username + "@hackerspace.pl" - - @cached_property - def is_active(self): - url = "https://kasownik.hackerspace.pl/api/judgement/{}.json" - try: - r = requests.get(url.format(self.username)) - return bool(r.json()["content"]) - except Exception as e: - logging.error("When getting data from Kasownik: {}".format(e)) - # Fail-safe. - return True - - @cached_property - def is_staff(self): - url = "https://capacifier.hackerspace.pl/staff/{}" - try: - r = requests.get(url.format(self.username)) - return "YES" in r.text - except Exception as e: - logging.error("When getting data from Capacifier: {}".format(e)) - return False - - def get_id(self): - return self.username - - -class LoginForm(FlaskForm): - username = StringField("username", validators=[DataRequired()]) - password = PasswordField("password", validators=[DataRequired()]) - remember = BooleanField("remember me") - - -@app.route("/") -@app.route("/profile") -@login_required -def profile(): - return render_template( - "profile.html", tokens=Token.query.filter(Token.user == current_user.username) - ) - - -@app.route("/token//revoke", methods=["POST"]) -@login_required -def token_revoke(id): - token = Token.query.filter( - Token.user == current_user.username, Token.id == id - ).first() - if not token: - flask.abort(404) - token.delete() - return redirect("/") - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - form = LoginForm() - next = flask.request.args.get("next") - if form.validate_on_submit(): - username, password = form.data["username"], form.data["password"] - if not check_credentials(username, password): - flash("Invalid username or password") - return render_template("login_oauth.html", form=form, next=next) - user = LDAPUserProxy(username) - if not user.is_active: - flash("User inactive - have you paid your membership fees?") - return render_template("login_oauth.html", form=form, next=next) - - login_user(user, form.data["remember"]) - flash("Logged in successfully.") - - return redirect(next or url_for("profile")) - - return render_template("login_oauth.html", form=form, next=next) - - -@app.route("/logout") -def logout(): - logout_user() - return redirect("/") - - -@login_manager.user_loader -def load_user(user_id): - return LDAPUserProxy(user_id) - - -# HSWAW specific endpoint -@app.route("/api/profile") -@app.route("/api/1/profile") -@oauth.require_oauth("profile:read") -def api_profile(): - user = LDAPUserProxy(flask.request.oauth.user) - return flask.jsonify( - email=user.email, - username=user.username, - gecos=user.gecos, - phone=user.phone, - personal_email=user.personal_email, - ) - - -# OpenIDConnect userinfo -@app.route("/api/1/userinfo") -@oauth.require_oauth("profile:read") -def api_userinfo(): - user = LDAPUserProxy(flask.request.oauth.user) - groups = [] - if user.is_staff: - groups.append("staff") - return flask.jsonify( - sub=user.username, - name=user.gecos, - email=user.email, - preferred_username=user.username, - nickname=user.username, - user_name=user.username, - user_id=user.username, - groups=groups, - ) - - -if __name__ == "__main__": - app.run("0.0.0.0", 8082, debug=True) diff --git a/bin/application_add.py b/bin/application_add.py index b2447b9..130774a 100644 --- a/bin/application_add.py +++ b/bin/application_add.py @@ -1,7 +1,11 @@ import random import string -from auth import app, Client, db +from website.app import create_app +from website.models import Client, db + +app = create_app() +app.app_context().push() app_name = "" app_description = "" @@ -10,15 +14,15 @@ confidential = "" redirect_uris = "" while not app_name: - app_name = raw_input("Application name (ie. Printmaster): ").strip() + app_name = input("Application name (ie. Printmaster): ").strip() while not app_description: - app_description = raw_input("Application description (ie. Print control software): ").strip() + app_description = input("Application description (ie. Print control software): ").strip() while not client_id: - client_id = raw_input("OAuth Client ID (ie. printmaster): ").strip() + client_id = input("OAuth Client ID (ie. printmaster): ").strip() while not confidential: - confidential = raw_input("Is the client confidential? Say yes for web apps, no for mobile apps: [yn] ").strip() + confidential = input("Is the client confidential? Say yes for web apps, no for mobile apps: [yn] ").strip() while not redirect_uris: - redirect_uris = raw_input("Whitespace-delimited redirect URIs: ").strip() + redirect_uris = input("Whitespace-delimited redirect URIs: ").strip() if confidential.lower().startswith('y'): confidential = True @@ -26,25 +30,25 @@ else: confidential = False -print "\n\nSummary\n-------" -print "Application name:", app_name -print "Application description:", app_description -print "Client ID:", client_id -print "Confidential client:", confidential -print "Redirect URIs:", redirect_uris +print("\n\nSummary\n-------") +print("Application name:", app_name) +print("Application description:", app_description) +print("Client ID:", client_id) +print("Confidential client:", confidential) +print("Redirect URIs:", redirect_uris) -while raw_input("Type YES to continue. ") != "YES": +while input("Type YES to continue. ") != "YES": pass c = Client() c.name = app_name c.description = app_description c.client_id = client_id -c.client_secret = ''.join([random.choice(string.uppercase + string.lowercase) for _ in range(32)]) +c.client_secret = ''.join([random.choice("0123456789abcdef") for _ in range(32)]) c.is_confidential = confidential c.redirect_uris_ = redirect_uris db.session.add(c) db.session.commit() -print "Client secret:", c.client_secret +print("Client secret:", c.client_secret) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..264d080 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +asn1crypto==0.24.0 +Authlib==0.11 +cached-property==1.5.1 +certifi==2019.6.16 +cffi==1.12.3 +chardet==3.0.4 +Click==7.0 +cryptography==2.7 +Flask==1.1.1 +Flask-Login==0.4.1 +Flask-SQLAlchemy==2.4.0 +Flask-WTF==0.14.2 +idna==2.8 +itsdangerous==1.1.0 +Jinja2==2.10.1 +MarkupSafe==1.1.1 +pyasn1==0.4.6 +pyasn1-modules==0.2.6 +pycparser==2.19 +python-ldap==3.2.0 +requests==2.22.0 +six==1.12.0 +SQLAlchemy==1.3.8 +urllib3==1.25.3 +Werkzeug==0.15.5 +WTForms==2.2.1 diff --git a/templates/oauthorize.html b/templates/oauthorize.html index 0e96670..6f0401f 100644 --- a/templates/oauthorize.html +++ b/templates/oauthorize.html @@ -3,12 +3,12 @@ {% block content %}
-

{{ client.name }} - {% if client.approved %}{% endif %} +

{{ grant.client.name }} + {% if grant.client.approved %}{% endif %}

This app would like to:

    - {% if 'profile:read' in scopes and 'profile:write' not in scopes %} + {% if ('profile:read' in scopes or 'openid' in scopes) and 'profile:write' not in scopes %}
  • Read your profile data. @@ -34,12 +34,11 @@ {% endif %}

On your ({{user.username}}) behalf.

-
- {{ form.csrf_token }} - + + {% if state %} {% endif %} diff --git a/templates/profile.html b/templates/profile.html index aba8cbf..288d20f 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -29,7 +29,7 @@ {{ token.expires }} - + {# FIXME #}
diff --git a/website/__init__.py b/website/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website/app.py b/website/app.py new file mode 100644 index 0000000..5454416 --- /dev/null +++ b/website/app.py @@ -0,0 +1,33 @@ +import logging + +from flask import Flask +from flask_login import LoginManager + +from .models import db +from .routes import bp +from .ldap import LDAPUser +from .oauth2 import config_oauth + + +def create_app(): + app = Flask("auth") + app.config.from_object(__name__) + app.config.from_pyfile("auth.cfg") + + setup_app(app) + return app + + +def setup_app(app): + logging.basicConfig( + level=logging.DEBUG, + format="[%(asctime)-15s] %(name)-10s %(levelname)7s: %(message)s", + ) + db.init_app(app) + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = "/login" + login_manager.user_loader(LDAPUser.by_login) + config_oauth(app) + app.register_blueprint(bp, url_prefix='') + diff --git a/website/forms.py b/website/forms.py new file mode 100644 index 0000000..9043c37 --- /dev/null +++ b/website/forms.py @@ -0,0 +1,9 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField +from wtforms.validators import DataRequired + +class LoginForm(FlaskForm): + username = StringField("username", validators=[DataRequired()]) + password = PasswordField("password", validators=[DataRequired()]) + remember = BooleanField("remember me") + diff --git a/website/ldap.py b/website/ldap.py new file mode 100644 index 0000000..dc07738 --- /dev/null +++ b/website/ldap.py @@ -0,0 +1,85 @@ +import logging +import re + +from cached_property import cached_property +from flask import current_app +import ldap +import requests + + +def check_credentials(username, password): + conn = ldap.initialize(current_app.config["LDAP_URL"]) + conn.start_tls_s() + try: + conn.simple_bind_s(current_app.config["DN_STRING"] % username, password) + return True + except ldap.LDAPError: + return False + +def _connect(): + conn = ldap.initialize(current_app.config["LDAP_URL"]) + conn.start_tls_s() + conn.simple_bind(current_app.config["LDAP_BIND_DN"], current_app.config["LDAP_BIND_PASSWORD"]) + return conn + + +class LDAPUser(object): + def __init__(self, username, ldap_data): + self.username = username + self.is_authenticated = True + self.is_anonymous = False + self.username = ldap_data.get("uid", [None])[0].decode() + self.gecos = ldap_data.get("gecos", [None])[0].decode() + self.mifare_hashes = [m.decode() for m in ldap_data.get("mifareIDHash", [])] + self.phone = ldap_data.get("mobile", [None])[0].decode() + self.personal_email = [m.decode() for m in ldap_data.get("mailRoutingAddress", [])] + + @classmethod + def by_login(cls, username): + username = re.sub(current_app.config["STRIP_RE"], "", username) + + conn = _connect() + res = conn.search_s( + current_app.config["PEOPLE_BASEDN"], + ldap.SCOPE_SUBTREE, + current_app.config["UID_LDAP_FILTER"] % username, + ) + if len(res) != 1: + raise Exception("No such username.") + + _, data = res[0] + return cls(username, data) + + def __repr__(self): + active = "active" if self.is_active else "inactive" + return "".format(self.username, active) + + @property + def email(self): + return self.username + "@hackerspace.pl" + + @cached_property + def is_active(self): + url = "https://kasownik.hackerspace.pl/api/judgement/{}.json" + try: + r = requests.get(url.format(self.username)) + return bool(r.json()["content"]) + except Exception as e: + logging.error("When getting data from Kasownik: {}".format(e)) + # Fail-safe. + return True + + @cached_property + def is_staff(self): + url = "https://capacifier.hackerspace.pl/staff/{}" + try: + r = requests.get(url.format(self.username)) + return "YES" in r.text + except Exception as e: + logging.error("When getting data from Capacifier: {}".format(e)) + return False + + def get_id(self): + return self.username + + diff --git a/website/models.py b/website/models.py new file mode 100644 index 0000000..33c8acc --- /dev/null +++ b/website/models.py @@ -0,0 +1,142 @@ +from datetime import datetime + +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() + + +class Client(db.Model): + # human readable name + name = db.Column(db.String(40)) + # human readable description + description = db.Column(db.String(400)) + + client_id = db.Column(db.String(40), primary_key=True) + client_secret = db.Column(db.String(55), unique=True, index=True, nullable=False) + + # public or confidential + is_confidential = db.Column(db.Boolean) + + redirect_uris_ = db.Column(db.Text) + default_scopes_ = db.Column(db.Text) + + # TODO + # approved = db.Column(db.Boolean, default=False) + approved = True + + @property + def client_type(self): + if self.is_confidential: + return "confidential" + return "public" + + @property + def redirect_uris(self): + if self.redirect_uris_: + return self.redirect_uris_.split() + return [] + + @property + def default_redirect_uri(self): + return self.redirect_uris[0] + + @property + def default_scopes(self): + if self.default_scopes_: + return self.default_scopes_.split() + return [] + + def check_response_type(self, response_type): + return True + + def check_redirect_uri(self, redirect_uri): + return redirect_uri in self.redirect_uris + + def check_requested_scopes(self, scopes): + return { + "profile:read", + "profile:write", + "password:write", + "users:read", + "openid", + }.issuperset(scopes) + + def check_token_endpoint_auth_method(self, method): + allowed = ['client_secret_post', 'client_secret_basic'] + if not self.is_confidential: + allowed.append('none') + return method in allowed + + def check_client_secret(self, secret): + return self.client_secret == secret + + def check_grant_type(self, grant_type): + return grant_type in ['authorization_code'] + + def check_client_type(self, client_type): + return client_type == self.client_type + + +class Grant(db.Model): + id = db.Column(db.Integer, primary_key=True) + + user = db.Column(db.String(40), nullable=False) + + client_id = db.Column( + db.String(40), db.ForeignKey("client.client_id"), nullable=False + ) + client = db.relationship("Client") + + code = db.Column(db.String(255), index=True, nullable=False) + + redirect_uri = db.Column(db.String(255)) + expires = db.Column(db.DateTime) + + _scopes = db.Column(db.Text) + + def is_expired(self): + return self.expires < datetime.utcnow() + + def get_redirect_uri(self): + return self.redirect_uri + + def get_scope(self): + return self._scopes + + +class Token(db.Model): + id = db.Column(db.Integer, primary_key=True) + client_id = db.Column( + db.String(40), db.ForeignKey("client.client_id"), nullable=False + ) + client = db.relationship("Client") + + user = db.Column(db.String(40), nullable=False) + + # currently only bearer is supported + token_type = db.Column(db.String(40)) + + access_token = db.Column(db.String(255), unique=True, nullable=False) + refresh_token = db.Column(db.String(255)) + expires = db.Column(db.DateTime) + _scopes = db.Column(db.Text) + + def delete(self): + db.session.delete(self) + db.session.commit() + return self + + @property + def scopes(self): + if self._scopes: + return self._scopes.split() + return [] + + def is_expired(self): + return self.expires < datetime.utcnow() + + def delete(self): + db.session.delete(self) + db.session.commit() + diff --git a/website/oauth2.py b/website/oauth2.py new file mode 100644 index 0000000..9ed3919 --- /dev/null +++ b/website/oauth2.py @@ -0,0 +1,138 @@ +from datetime import datetime, timedelta + +from authlib.flask.oauth2 import AuthorizationServer, ResourceProtector +from authlib.oauth2.rfc6749 import grants +from authlib.oidc.core import grants as oidgrants +from authlib.oauth2.rfc6750 import BearerTokenValidator +from authlib.oauth2.rfc7009 import RevocationEndpoint +from werkzeug.security import gen_salt + +from .models import db +from .models import Client, Grant, Token +from .ldap import LDAPUser, check_credentials + + +class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): + def create_authorization_code(self, client, user, request): + code = gen_salt(48) + expires = datetime.utcnow() + timedelta(seconds=100) + item = Grant( + code=code, + client_id=client.client_id, + redirect_uri=request.redirect_uri, + _scopes=request.scope, + user=user.username, + expires=expires, + ) + db.session.add(item) + db.session.commit() + return code + + def parse_authorization_code(self, code, client): + item = Grant.query.filter_by( + code=code, client_id=client.client_id).first() + if item and not item.is_expired(): + return item + + def delete_authorization_code(self, authorization_code): + db.session.delete(authorization_code) + db.session.commit() + + def authenticate_user(self, authorization_code): + return LDAPUser.by_login(authorization_code.user) + + +class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant): + def authenticate_user(self, username, password): + if not check_credentials(username, password): + return None + return LDAPUser.by_login(username) + + +class RefreshTokenGrant(grants.RefreshTokenGrant): + def authenticate_refresh_token(self, refresh_token): + token = Token.query.filter_by(refresh_token=refresh_token).first() + if token and not token.is_expired(): + return token + + def authenticate_user(self, credential): + return LDAPUser.by_login(credentials.user_id) + + +def query_client(client_id): + return Client.query.filter_by(client_id=client_id).first() + + +def save_token(token, request): + user_id = None + if request.user: + user_id = request.user.username + + client = request.client + + # HACK: convert to model field names + t = dict( + token_type=token['token_type'], + access_token=token.get('access_token'), + refresh_token=token.get('refresh_token'), + expires=datetime.utcnow() + timedelta(seconds=token['expires_in']), + _scopes=token['scope'], + ) + + item = Token(client_id=client.client_id, user=user_id, **t) + db.session.add(item) + db.session.commit() + + +authorization = AuthorizationServer( + query_client=query_client, + save_token=save_token, +) +require_oauth = ResourceProtector() + + +class _BearerTokenValidator(BearerTokenValidator): + def authenticate_token(self, token_string): + return Token.query.filter_by(access_token=token_string).first() + + def request_invalid(self, request): + return False + + def token_revoked(self,token): + return False + + +class _RevocationEndpoint(RevocationEndpoint): + def query_token(self, token, token_type_hint, client): + q = Token.query.filter_by(client_id=client.client_id) + + if token_type_hint == 'access_token': + return q.filter_by(access_token=token).first() + elif token_type_hint == 'refresh_token': + return q.filter_by(refresh_token=token).first() + + token = q.filter_by(access_token=token).first() + if token is None: + return token + + return q.filter_by(refresh_token=token).first() + + def revoke_token(self, token): + token.delete() + + +def config_oauth(app): + authorization.init_app(app) + + # support all grants + authorization.register_grant(grants.ImplicitGrant) + authorization.register_grant(grants.ClientCredentialsGrant) + authorization.register_grant(AuthorizationCodeGrant) + authorization.register_grant(PasswordGrant) + authorization.register_grant(RefreshTokenGrant) + + # support revocation + authorization.register_endpoint(_RevocationEndpoint) + + # protect resource + require_oauth.register_token_validator(_BearerTokenValidator()) diff --git a/website/routes.py b/website/routes.py new file mode 100644 index 0000000..8ace43a --- /dev/null +++ b/website/routes.py @@ -0,0 +1,133 @@ +from authlib.oauth2 import OAuth2Error +from flask import Blueprint, render_template, jsonify, request, flash, redirect, current_app +from flask_login import login_required, current_user, login_user + +from .forms import LoginForm +from .ldap import LDAPUser, check_credentials +from .oauth2 import authorization, require_oauth +from .models import Token + + +bp = Blueprint(__name__, 'sso') + + +@bp.route("/") +@bp.route("/profile") +@login_required +def profile(): + tokens = Token.query.filter(Token.user == current_user.username) + return render_template( + "profile.html", tokens=tokens + ) + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm() + next = request.args.get("next") + if form.validate_on_submit(): + username, password = form.data["username"], form.data["password"] + if not check_credentials(username, password): + flash("Invalid username or password") + return render_template("login_oauth.html", form=form, next=next) + user = LDAPUser.by_login(username) + if not user.is_active: + flash("User inactive - have you paid your membership fees?") + return render_template("login_oauth.html", form=form, next=next) + + login_user(user, form.data["remember"]) + flash("Logged in successfully.") + + return redirect(next or url_for("profile")) + + return render_template("login_oauth.html", form=form, next=next) + + +@bp.route("/logout") +def logout(): + logout_user() + return redirect("/") + + +# HSWAW specific endpoint +@bp.route("/api/profile") +@bp.route("/api/1/profile") +@require_oauth("profile:read") +def api_profile(): + user = LDAPUser.by_login(request.oauth.user) + return jsonify( + email=user.email, + username=user.username, + gecos=user.gecos, + phone=user.phone, + personal_email=user.personal_email, + ) + + +# OpenIDConnect userinfo +@bp.route("/api/1/userinfo") +@require_oauth("profile:read") +def api_userinfo(): + user = LDAPUser.by_login(request.oauth.user) + groups = [] + if user.is_staff: + groups.append("staff") + return jsonify( + sub=user.username, + name=user.gecos, + email=user.email, + preferred_username=user.username, + nickname=user.username, + user_name=user.username, + user_id=user.username, + groups=groups, + ) + + +@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: + print(error) + return 'Could not authorize: ' + error.error + + return render_template("oauthorize.html", user=current_user, grant=grant, scopes=grant.request.scope.split()) + + grant_user = None + if request.form['confirm']: + grant_user = current_user + + return authorization.create_authorization_response(grant_user=grant_user) + + +@bp.route("/oauth/token", methods=["GET", "POST"]) +def access_token(): + return authorization.create_token_response() + + +@bp.route("/token//revoke", methods=["POST"]) +@login_required +def token_revoke(id): + token = Token.query.filter(Token.user == current_user.username, Token.id == id).first() + if not token: + flask.abort(404) + token.delete() + return redirect('/') + + +@bp.route("/oauth/revoke", methods=["POST"]) +def oauth_token_revoke(): + return authorization.create_endpoint_response('revocation') + +@bp.route("/.well-known/openid-configuration") +def oidc_configureation(): + issuer = current_app.config['ISSUER_URL'] + return jsonify({ + "issuer": issuer, + "authorization_endpint": issuer + "/oauth/authorize", + "token_endpint": issuer + "/oauth/token", + "token_endpint": issuer + "/api/1/userinfo", + })