diff --git a/README.md b/README.md index 18ee6ba02ae88a6c4492e3ae0be81b7264f54905..8c96f8c0e35de0632f7ecc21c1f4f2d92d35b786 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 7280d0ba62c68d9a7ad02307f4e4e996045fae07..720ad165e9e3fe571f738bb796ee4a76ac8c28fe 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 0cf302b0447cb4f58add03f4aba1ee7b033310da..5ce34d2712abb845893a6e45dce561c9c8adb986 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 0000000000000000000000000000000000000000..656390049779db11f3fbd9a16498218b18982068 --- /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 0000000000000000000000000000000000000000..dea62cd9c1aabd42390367d0c926238c3557cb46 --- /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 0000000000000000000000000000000000000000..2e7dba29205623d85560aca11eb53b4eabaf063b --- /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 0000000000000000000000000000000000000000..32380da948329bd91b58b3e22cd3e8116d29fabf --- /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)