From 177c8b9cd27e384ce596fbe23069c2f2102354cf Mon Sep 17 00:00:00 2001
From: julian <cccv@verkrepelt.de>
Date: Sat, 24 Oct 2020 14:53:23 +0000
Subject: [PATCH] Add OAuth2 Single-Sign-On, closes #8

---
 README.md                        |  27 +++++++
 uffd/__init__.py                 |   4 +-
 uffd/default_config.cfg          |   7 ++
 uffd/oauth2/__init__.py          |   3 +
 uffd/oauth2/models.py            | 118 +++++++++++++++++++++++++++++++
 uffd/oauth2/templates/error.html |  24 +++++++
 uffd/oauth2/views.py             | 113 +++++++++++++++++++++++++++++
 7 files changed, 294 insertions(+), 2 deletions(-)
 create mode 100644 uffd/oauth2/__init__.py
 create mode 100644 uffd/oauth2/models.py
 create mode 100644 uffd/oauth2/templates/error.html
 create mode 100644 uffd/oauth2/views.py

diff --git a/README.md b/README.md
index 18ee6ba0..8c96f8c0 100644
--- a/README.md
+++ b/README.md
@@ -26,3 +26,30 @@ Use uwsgi.
 ## python style conventions
 
 tabs.
+
+## OAuth2 Single-Sign-On Provider
+
+Other services can use uffd as an OAuth2.0-based authentication provider.
+The required credentials (client_id, client_secret and redirect_uris) for these services are defined in the config.
+The services need to be setup to use the following URLs with the Authorization Code Flow:
+
+* `/oauth2/authorize`: authorization endpoint
+* `/oauth2/token`: token request endpoint
+* `/oauth2/userinfo`: endpoint that provides information about the current user
+
+The userinfo endpoint returns json data with the following structure:
+
+```
+{
+  "id": 10000,
+  "name": "Test User",
+  "nickname": "testuser"
+  "email": "testuser@example.com",
+  "groups": [
+    "uffd_access",
+    "users"
+  ],
+}
+```
+
+`id` is the uidNumber, `name` the display name (cn) and `nickname` the uid of the user's LDAP object.
diff --git a/uffd/__init__.py b/uffd/__init__.py
index 7280d0ba..720ad165 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -39,10 +39,10 @@ def create_app(test_config=None):
 
 	db.init_app(app)
 	# pylint: disable=C0415
-	from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa
+	from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa, oauth2
 	# pylint: enable=C0415
 
-	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp:
+	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp + oauth2.bp:
 		app.register_blueprint(i)
 
 	@app.route("/")
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 0cf302b0..5ce34d27 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -34,6 +34,13 @@ SQLALCHEMY_TRACK_MODIFICATIONS=False
 
 FOOTER_LINKS=[{"url": "https://example.com", "title": "example"}]
 
+OAUTH2_CLIENTS={
+	#'test_client_id' : {'client_secret': 'random_secret', 'redirect_uris': ['https://example.com/oauth']},
+	# You can optionally restrict access to users with a certain group. Set 'required_group' to the name of an LDAP group name or a list of groups.
+	# ... 'required_group': 'test_access_group' ... only allows users with group "test_access_group" access
+	# ... 'required_group': ['groupa', ['groupb', 'groupc']] ... allows users with group "groupa" as well as users with both "groupb" and "groupc" access
+}
+
 # do NOT set in production
 
 #TEMPLATES_AUTO_RELOAD=True
diff --git a/uffd/oauth2/__init__.py b/uffd/oauth2/__init__.py
new file mode 100644
index 00000000..65639004
--- /dev/null
+++ b/uffd/oauth2/__init__.py
@@ -0,0 +1,3 @@
+from .views import bp as _bp
+
+bp = [_bp]
diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py
new file mode 100644
index 00000000..dea62cd9
--- /dev/null
+++ b/uffd/oauth2/models.py
@@ -0,0 +1,118 @@
+from flask import current_app
+from sqlalchemy import Column, Integer, String, DateTime, Text
+
+from uffd.database import db
+from uffd.user.models import User
+
+class OAuth2Client:
+	def __init__(self, client_id, client_secret, redirect_uris, required_group=None):
+		self.client_id = client_id
+		self.client_secret = client_secret
+		# We only support the Authorization Code Flow for confidential (server-side) clients
+		self.client_type = 'confidential'
+		self.redirect_uris = redirect_uris
+		self.default_scopes = ['profile']
+		self.required_group = required_group
+
+	@classmethod
+	def from_id(cls, client_id):
+		return OAuth2Client(client_id, **current_app.config['OAUTH2_CLIENTS'][client_id])
+
+	@property
+	def default_redirect_uri(self):
+		return self.redirect_uris[0]
+
+	def access_allowed(self, user):
+		if not self.required_group:
+			return True
+		user_groups = {group.name for group in user.get_groups()}
+		group_sets = self.required_group
+		if isinstance(group_sets, str):
+			group_sets = [group_sets]
+		for group_set in group_sets:
+			if isinstance(group_set, str):
+				group_set = [group_set]
+			if set(group_set) - user_groups == set():
+				return True
+		return False
+
+class OAuth2Grant(db.Model):
+	__tablename__ = 'oauth2grant'
+	id = Column(Integer, primary_key=True)
+
+	user_dn = Column(String(128))
+
+	@property
+	def user(self):
+		return User.from_ldap_dn(self.user_dn)
+
+	@user.setter
+	def user(self, newuser):
+		self.user_dn = newuser.dn
+
+	client_id = Column(String(40))
+
+	@property
+	def client(self):
+		return OAuth2Client.from_id(self.client_id)
+
+	@client.setter
+	def client(self, newclient):
+		self.client_id = newclient.client_id
+
+	code = Column(String(255), index=True, nullable=False)
+	redirect_uri = Column(String(255))
+	expires = Column(DateTime)
+
+	_scopes = Column(Text)
+	@property
+	def scopes(self):
+		if self._scopes:
+			return self._scopes.split()
+		return []
+
+	def delete(self):
+		db.session.delete(self)
+		db.session.commit()
+		return self
+
+class OAuth2Token(db.Model):
+	__tablename__ = 'oauth2token'
+	id = Column(Integer, primary_key=True)
+
+	user_dn = Column(String(128))
+	@property
+	def user(self):
+		return User.from_ldap_dn(self.user_dn)
+
+	@user.setter
+	def user(self, newuser):
+		self.user_dn = newuser.dn
+
+	client_id = Column(String(40))
+
+	@property
+	def client(self):
+		return OAuth2Client.from_id(self.client_id)
+
+	@client.setter
+	def client(self, newclient):
+		self.client_id = newclient.client_id
+
+	# currently only bearer is supported
+	token_type = Column(String(40))
+	access_token = Column(String(255), unique=True)
+	refresh_token = Column(String(255), unique=True)
+	expires = Column(DateTime)
+
+	_scopes = Column(Text)
+	@property
+	def scopes(self):
+		if self._scopes:
+			return self._scopes.split()
+		return []
+
+	def delete(self):
+		db.session.delete(self)
+		db.session.commit()
+		return self
diff --git a/uffd/oauth2/templates/error.html b/uffd/oauth2/templates/error.html
new file mode 100644
index 00000000..2e7dba29
--- /dev/null
+++ b/uffd/oauth2/templates/error.html
@@ -0,0 +1,24 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<h1>OAuth2.0 Authorization Error</h1>
+<p><b>Error: {{ error }}</b> {{ '(' + error_description + ')' if error_description else '' }}</p>
+{% if args %}
+<p>Parameters:</p>
+<ul>
+	{% for key, value in args.items() %}
+	<li>{{ key }}={{ value }}</li>
+	{% endfor %}
+</ul>
+{% endif %}
+
+<hr>
+
+<p>OAuth2.0 Server URLs:</p>
+<ul>
+	<li>{{ url_for('oauth2.authorize', _external=True) }}</li>
+	<li>{{ url_for('oauth2.token', _external=True) }}</li>
+	<li>{{ url_for('oauth2.userinfo', _external=True) }}</li>
+</ul>
+
+{% endblock %}
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
new file mode 100644
index 00000000..32380da9
--- /dev/null
+++ b/uffd/oauth2/views.py
@@ -0,0 +1,113 @@
+import datetime
+import functools
+
+from flask import Blueprint, request, jsonify, render_template
+from werkzeug.datastructures import ImmutableMultiDict
+
+from flask_oauthlib.provider import OAuth2Provider
+
+from uffd.database import db
+from uffd.session.views import get_current_user, login_required
+from .models import OAuth2Client, OAuth2Grant, OAuth2Token
+
+oauth = OAuth2Provider()
+
+@oauth.clientgetter
+def load_client(client_id):
+	return OAuth2Client.from_id(client_id)
+
+@oauth.grantgetter
+def load_grant(client_id, code):
+	return OAuth2Grant.query.filter_by(client_id=client_id, code=code).first()
+
+@oauth.grantsetter
+def save_grant(client_id, code, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
+	expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100)
+	grant = OAuth2Grant(user_dn=get_current_user().dn, client_id=client_id,
+		code=code['code'], redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes))
+	db.session.add(grant)
+	db.session.commit()
+	return grant
+
+@oauth.tokengetter
+def load_token(access_token=None, refresh_token=None):
+	if access_token:
+		return OAuth2Token.query.filter_by(access_token=access_token).first()
+	if refresh_token:
+		return OAuth2Token.query.filter_by(refresh_token=refresh_token).first()
+	return None
+
+@oauth.tokensetter
+def save_token(token_data, oauthreq, *args, **kwargs): # pylint: disable=unused-argument
+	OAuth2Token.query.filter_by(client_id=oauthreq.client.client_id, user_dn=oauthreq.user.dn).delete()
+	expires_in = token_data.get('expires_in')
+	expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)
+	tok = OAuth2Token(
+		user_dn=oauthreq.user.dn,
+		client_id=oauthreq.client.client_id,
+		token_type=token_data['token_type'],
+		access_token=token_data['access_token'],
+		refresh_token=token_data['refresh_token'],
+		expires=expires,
+		_scopes=' '.join(oauthreq.scopes)
+	)
+	db.session.add(tok)
+	db.session.commit()
+	return tok
+
+bp = Blueprint('oauth2', __name__, url_prefix='/oauth2/', template_folder='templates')
+
+@bp.record
+def init(state):
+	state.app.config.setdefault('OAUTH2_PROVIDER_ERROR_ENDPOINT', 'oauth2.error')
+	oauth.init_app(state.app)
+
+# flask-oauthlib has the bug to require the scope parameter for authorize
+# requests, which is actually optional according to the OAuth2.0 spec.
+# We don't really use scopes and this requirement just complicates the
+# configuration of clients.
+# See also: https://github.com/lepture/flask-oauthlib/pull/320
+def inject_scope(func):
+	@functools.wraps(func)
+	def decorator(*args, **kwargs):
+		args = request.args.to_dict()
+		if 'profile' not in args:
+			args['scope'] = 'profile'
+		request.args = ImmutableMultiDict(args)
+		return func(*args, **kwargs)
+	return decorator
+
+@bp.route('/authorize', methods=['GET', 'POST'])
+@login_required()
+@inject_scope
+@oauth.authorize_handler
+def authorize(*args, **kwargs): # pylint: disable=unused-argument
+	# Here we would normally ask the user, if he wants to give the requesting
+	# service access to his data. Since we only have trusted services (the
+	# clients defined in the server config), we don't ask for consent.
+	client = kwargs['request'].client
+	return client.access_allowed(get_current_user())
+
+@bp.route('/token', methods=['GET', 'POST'])
+@oauth.token_handler
+def token():
+	return None
+
+@bp.route('/userinfo')
+@oauth.require_oauth('profile')
+def userinfo():
+	user = request.oauth.user
+	return jsonify(
+		id=user.uid,
+		name=user.displayname,
+		nickname=user.loginname,
+		email=user.mail,
+		groups=[group.name for group in user.get_groups()]
+	)
+
+@bp.route('/error')
+def error():
+	args = dict(request.values)
+	err = args.pop('error', 'unknown')
+	error_description = args.pop('error_description', '')
+	return render_template('error.html', error=err, error_description=error_description, args=args)
-- 
GitLab