Skip to content
Snippets Groups Projects
Commit 177c8b9c authored by Julian's avatar Julian Committed by nd
Browse files

Add OAuth2 Single-Sign-On, closes #8

parent f7038695
No related branches found
No related tags found
No related merge requests found
...@@ -26,3 +26,30 @@ Use uwsgi. ...@@ -26,3 +26,30 @@ Use uwsgi.
## python style conventions ## python style conventions
tabs. 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.
...@@ -39,10 +39,10 @@ def create_app(test_config=None): ...@@ -39,10 +39,10 @@ def create_app(test_config=None):
db.init_app(app) db.init_app(app)
# pylint: disable=C0415 # 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 # 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.register_blueprint(i)
@app.route("/") @app.route("/")
......
...@@ -34,6 +34,13 @@ SQLALCHEMY_TRACK_MODIFICATIONS=False ...@@ -34,6 +34,13 @@ SQLALCHEMY_TRACK_MODIFICATIONS=False
FOOTER_LINKS=[{"url": "https://example.com", "title": "example"}] 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 # do NOT set in production
#TEMPLATES_AUTO_RELOAD=True #TEMPLATES_AUTO_RELOAD=True
......
from .views import bp as _bp
bp = [_bp]
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
{% 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 %}
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)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment