From 9dc0fbb4fed5118a953ba8f53f0a6d4206bb48b7 Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Wed, 6 Sep 2023 14:28:06 +0200 Subject: [PATCH] Switch to OIDC authentication --- requirements.txt | 3 +- web/__init__.py | 74 ++++++++++------------------------------- web/auth.py | 73 ++++++++++++++++++++++++++++------------ web/errors.py | 17 ++++++++++ web/templates/base.html | 2 +- 5 files changed, 88 insertions(+), 81 deletions(-) create mode 100644 web/errors.py diff --git a/requirements.txt b/requirements.txt index e6ed309..da47cfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ +authlib click flask flask-login -flask-ldap3-login +ldap3 diff --git a/web/__init__.py b/web/__init__.py index 5587f2e..7733ce3 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -3,7 +3,6 @@ import syslog import secrets import flask -import flask_ldap3_login import flask_login def create_app(test_config=None): @@ -14,13 +13,13 @@ def create_app(test_config=None): settings = { 'secret_key': secrets.token_hex(), 'ldap_host': '', - 'ldap_port': '636', 'ldap_user': '', 'ldap_pass': '', - 'ldap_admin': '', 'ldap_base_dn': '', - 'ldap_user_dn': '', - 'ldap_login_attr': 'userPrincipalName', + 'oidc_tenant': '', + 'oidc_client_id': '', + 'oidc_client_secret': '', + 'admin_group': '', 'admin_mail': '', 'wg_endpoint': '', 'wg_port': '51820', @@ -35,18 +34,15 @@ def create_app(test_config=None): db.write('settings', settings) app.config['SECRET_KEY'] = settings.get('secret_key', '') - app.config['LDAP_USE_SSL'] = True - app.config['LDAP_HOST'] = settings.get('ldap_host', '') - app.config['LDAP_PORT'] = int(settings.get('ldap_port', '636')) - app.config['LDAP_BASE_DN'] = settings.get('ldap_base_dn', '') - app.config['LDAP_USER_DN'] = settings.get('ldap_user_dn', '') - app.config['LDAP_BIND_USER_DN'] = settings.get('ldap_user', '') - app.config['LDAP_BIND_USER_PASSWORD'] = settings.get('ldap_pass', '') - app.config['LDAP_USER_LOGIN_ATTR'] = settings.get('ldap_login_attr', 'userPrincipalName') - app.config['LDAP_USER_SEARCH_SCOPE'] = 'SUBTREE' from . import auth - app.register_blueprint(auth.blueprint) + auth.init_app(app) + + from . import errors + errors.init_app(app) + + from . import system + system.init_app(app) from . import config app.register_blueprint(config.blueprint) @@ -63,36 +59,6 @@ def create_app(test_config=None): from . import vpn app.register_blueprint(vpn.blueprint) - from . import system - system.init_app(app) - - login_manager = flask_login.LoginManager(app) - ldap_manager = flask_ldap3_login.LDAP3LoginManager(app) - users = {} - - @login_manager.user_loader - def load_user(id): - return users.get(id) - - @ldap_manager.save_user - def save_user(dn, username, data, memberships): - user = auth.User(dn, username, data) - users[dn] = user - return user - - @login_manager.unauthorized_handler - def unauth_handler(): - return flask.redirect(flask.url_for('auth.login', next=flask.request.endpoint)) - - @app.errorhandler(TimeoutError) - def timeout_error(e): - return flask.render_template('busy.html') - - @app.errorhandler(Exception) - def internal_server_error(e): - return flask.Response(f'something went catastrophically wrong: {e}', - status=500, mimetype='text/plain') - @app.route('/') @flask_login.login_required def home(): @@ -101,17 +67,11 @@ def create_app(test_config=None): @app.route('/nodes') @flask_login.login_required def nodes(): - try: - if not flask_login.current_user.is_admin: - return flask.Response('forbidden', status=403, mimetype='text/plain') - with db.locked('nodes'): - version = db.load('settings').get('version') - nodes = db.read('nodes') - return flask.render_template('nodes.html', version=version, nodes=nodes) - except TimeoutError: - return flask.render_template('busy.html') - except Exception as e: - return flask.Response(f'something went catastrophically wrong: {e}', - status=400, mimetype='text/plain') + if not flask_login.current_user.is_admin: + return flask.Response('forbidden', status=403, mimetype='text/plain') + with db.locked('nodes'): + version = db.load('settings').get('version') + nodes = db.read('nodes') + return flask.render_template('nodes.html', version=version, nodes=nodes) return app diff --git a/web/auth.py b/web/auth.py index fe04671..4409578 100644 --- a/web/auth.py +++ b/web/auth.py @@ -1,39 +1,68 @@ +import authlib.integrations.flask_client import flask import flask_login -import flask_ldap3_login.forms +import urllib.parse from . import db -blueprint = flask.Blueprint('auth', __name__, url_prefix='/auth') +login_manager = None +auth = None +users = {} class User(flask_login.UserMixin): - def __init__(self, dn, username, data): - self.dn = dn - self.username = username - self.data = data - self.groups = set(data.get('memberOf', ())) + def __init__(self, userinfo): + self.username = userinfo['preferred_username'] + self.groups = set(userinfo.get('groups', ())) + self.data = userinfo try: - self.is_admin = db.load('settings').get('ldap_admin') in self.groups + self.is_admin = db.load('settings').get('admin_group') in self.groups except: self.is_admin = False def __repr__(self): - return self.dn + return f'{self.username} {self.groups}' def get_id(self): - return self.dn + return self.username -@blueprint.route('/login', methods=['GET', 'POST']) -def login(): - form = flask_ldap3_login.forms.LDAPLoginForm() - if form.validate_on_submit(): - flask_login.login_user(form.user) +def init_app(app): + settings = db.load('settings') + login_manager = flask_login.LoginManager(app) + oauth = authlib.integrations.flask_client.OAuth(app) + oauth.register( + name='azure', + server_metadata_url=f'https://login.microsoftonline.com/{settings.get("oidc_tenant")}/v2.0/.well-known/openid-configuration', + client_id=settings.get('oidc_client_id'), + client_secret=settings.get('oidc_client_secret'), + client_kwargs={'scope': 'openid profile email'}) + + @login_manager.user_loader + def load_user(username): + return users.get(username) + + @login_manager.unauthorized_handler + def unauth_handler(): + return flask.redirect(flask.url_for('login', next=flask.request.endpoint)) + + @app.route('/login') + def login(): + return oauth.azure.authorize_redirect(flask.url_for('auth', _external=True)) + + @app.route('/auth') + def auth(): + token = oauth.azure.authorize_access_token() + user = users[user.username] = User(oauth.azure.parse_id_token(token)) + flask_login.login_user(user) return flask.redirect('/') - return flask.render_template('auth/login.html', form=form) - -@blueprint.route('/logout') -@flask_login.login_required -def logout(): - flask_login.logout_user() - return flask.redirect('/') + @app.route('/logout') + def logout(): + flask_login.logout_user() + return flask.redirect( + f'https://login.microsoftonline.com/common/oauth2/v2.0/logout?' + + urllib.parse.urlencode( + { + 'returnTo': flask.url_for('home', _external=True), + 'client_id': settings.get('oidc_client_id') + }, + quote_via=urllib.parse.quote_plus)) diff --git a/web/errors.py b/web/errors.py new file mode 100644 index 0000000..360a3dd --- /dev/null +++ b/web/errors.py @@ -0,0 +1,17 @@ +import flask +import werkzeug.exceptions + +def init_app(app): + @app.errorhandler(werkzeug.exceptions.HTTPException) + def http_error(e): + return e + + @app.errorhandler(TimeoutError) + def timeout_error(e): + return flask.render_template('busy.html') + + @app.errorhandler(Exception) + def internal_server_error(e): + return flask.Response(f'something went catastrophically wrong: {e}', + status=500, mimetype='text/plain') + diff --git a/web/templates/base.html b/web/templates/base.html index 7a2c545..2da3f8f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -53,7 +53,7 @@ ul.keys a { {% if current_user.is_authenticated %}
{{ current_user['username'] }} | -Odjava +Odjava
{% endif %}