From 83612f69485c44bd23e29390a1d927f46b6fc608 Mon Sep 17 00:00:00 2001 From: Sistason <c3infra@sistason.de> Date: Sun, 14 Mar 2021 21:30:59 +0100 Subject: [PATCH] =?UTF-8?q?ordentlich=20machen=20nach=20r=C3=BCcksprache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move all ldap-code to ldap.py - add ENABLE_PASSWORDRESET. - when using service/user conn, add only the blueprints that can do those - Add documentation to config+Readme about user connections --- README.md | 9 +++++ uffd/__init__.py | 9 +++-- uffd/default_config.cfg | 4 +++ uffd/ldap.py | 40 ++++++++++++++++----- uffd/session/templates/login.html | 2 ++ uffd/session/views.py | 58 ++++++++----------------------- uffd/signup/views.py | 2 +- 7 files changed, 70 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 859d4e16..610ae70e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,15 @@ Use uwsgi. tabs. +## Bind with service account or as user? + +Uffd can use a dedicated service account for LDAP operations by setting `LDAP_SERVICE_BIND_DN`. +Or it uses the credentials of the currently logged in user, by not setting `LDAP_SERVICE_BIND_DN`. + +If you choose to run with user credentials, some features are not available, like password resets +or self signup, since in both cases, no user credentials can exist. + + ## OAuth2 Single-Sign-On Provider Other services can use uffd as an OAuth2.0-based authentication provider. diff --git a/uffd/__init__.py b/uffd/__init__.py index d055976a..8c1d4e56 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -46,8 +46,13 @@ def create_app(test_config=None): # pylint: disable=too-many-locals from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup # pylint: enable=C0415 - for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp + signup.bp: - app.register_blueprint(i) + if app.config['LDAP_SERVICE_BIND_DN']: + for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp + signup.bp: + app.register_blueprint(i) + else: + app.config['ENABLE_PASSWORDRESET'] = False + for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + mfa.bp + oauth2.bp + services.bp: + app.register_blueprint(i) @app.route("/") def index(): #pylint: disable=unused-variable diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 727cb849..5f9b2b02 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -37,6 +37,8 @@ LDAP_MAIL_UID_ATTRIBUTE="uid" LDAP_MAIL_RECEIVERS_ATTRIBUTE="mailacceptinggeneralid" LDAP_MAIL_DESTINATIONS_ATTRIBUTE="maildrop" +# If you do not set the service bind_dn, connections use the user credentials. +# When using a user connection, some features are not available, since they require a service connection LDAP_SERVICE_BIND_DN="" LDAP_SERVICE_BIND_PASSWORD="" LDAP_SERVICE_URL="ldapi:///" @@ -60,6 +62,8 @@ MAIL_FROM_ADDRESS='foo@bar.com' # Do not enable this on a public service! There is no spam protection implemented at the moment. SELF_SIGNUP=False +# PASSWORDRESET is not available when not using a service connection +ENABLE_PASSWORDRESET=True #MFA_ICON_URL = 'https://example.com/logo.png' #MFA_RP_ID = 'example.com' # If unset, hostname from current request is used diff --git a/uffd/ldap.py b/uffd/ldap.py index 9d49a10c..855feea4 100644 --- a/uffd/ldap.py +++ b/uffd/ldap.py @@ -1,10 +1,11 @@ from flask import current_app, request, abort, session import ldap3 -from ldap3.core.exceptions import LDAPBindError +from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError from ldapalchemy import LDAPMapper, LDAPCommitError # pylint: disable=unused-import from ldapalchemy.model import Query +from ldapalchemy.core import encode_filter class FlaskQuery(Query): @@ -21,6 +22,30 @@ class FlaskQuery(Query): return res +def test_user_bind(bind_dn, bind_pw): + try: + if current_app.config.get('LDAP_SERVICE_MOCK', False): + # Since we reuse the same conn for all calls to `user_conn()` we + # simulate the password check by rebinding. Note that ldap3's mocking + # implementation just compares the string in the objects's userPassword + # field with the password, no support for hashing or OpenLDAP-style + # password-prefixes ("{PLAIN}..." or "{ssha512}..."). + conn = ldap.get_connection() + if not conn.rebind(bind_dn, bind_pw): + return False + else: + server = ldap3.Server(current_app.config["LDAP_SERVICE_URL"]) + conn = connect_and_bind_to_ldap(server, bind_dn, bind_pw) + if not conn: + return False + except (LDAPBindError, LDAPPasswordIsMandatoryError): + return False + + conn.search(conn.user, encode_filter(current_app.config["LDAP_USER_SEARCH_FILTER"])) + conn.unbind() + return len(conn.entries) == 1 + + def connect_and_bind_to_ldap(server, bind_dn, bind_pw): # Using auto_bind cannot close the connection, so define the connection with extra steps _connection = ldap3.Connection(server, bind_dn, bind_pw) @@ -30,7 +55,7 @@ def connect_and_bind_to_ldap(server, bind_dn, bind_pw): _connection.start_tls(read_server_info=False) if not _connection.bind(read_server_info=True): _connection.unbind() - return None + raise LDAPBindError return _connection @@ -65,6 +90,7 @@ class FlaskLDAPMapper(LDAPMapper): current_app.ldap_mock.bind() return current_app.ldap_mock server = ldap3.Server(current_app.config["LDAP_SERVICE_URL"], get_info=ldap3.ALL) + # If the configured LDAP service bind_dn is empty, connect to LDAP as a user if current_app.config['LDAP_SERVICE_BIND_DN']: bind_dn = current_app.config["LDAP_SERVICE_BIND_DN"] @@ -72,12 +98,10 @@ class FlaskLDAPMapper(LDAPMapper): else: bind_dn = session['user_dn'] bind_pw = session['user_pw'] - - _connection = connect_and_bind_to_ldap(server, bind_dn, bind_pw) - if not _connection: - raise LDAPBindError - - request.ldap_connection = _connection + try: + request.ldap_connection = connect_and_bind_to_ldap(server, bind_dn, bind_pw) + except (LDAPBindError, LDAPPasswordIsMandatoryError): + return None return request.ldap_connection diff --git a/uffd/session/templates/login.html b/uffd/session/templates/login.html index e3a17c57..15c0f8f0 100644 --- a/uffd/session/templates/login.html +++ b/uffd/session/templates/login.html @@ -25,7 +25,9 @@ {% if config['SELF_SIGNUP'] %} <a href="{{ url_for("signup.signup_start") }}" class="float-left">Register</a> {% endif %} + {% if config['ENABLE_PASSWORDRESET'] %} <a href="{{ url_for("selfservice.forgot_password") }}" class="float-right">Forgot Password?</a> + {% endif %} </div> </div> </div> diff --git a/uffd/session/views.py b/uffd/session/views.py index 3b5027ee..f1b16f8a 100644 --- a/uffd/session/views.py +++ b/uffd/session/views.py @@ -4,12 +4,8 @@ import functools from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session, abort -import ldap3 -from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError -from ldapalchemy.core import encode_filter - from uffd.user.models import User -from uffd.ldap import ldap, connect_and_bind_to_ldap +from uffd.ldap import ldap, test_user_bind from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/') @@ -18,41 +14,19 @@ login_ratelimit = Ratelimit('login', 1*60, 3) def login_get_user(loginname, password): dn = User(loginname=loginname).dn - if current_app.config.get('LDAP_SERVICE_MOCK', False): - conn = ldap.get_connection() - # Since we reuse the same conn for all calls to `user_conn()` we - # simulate the password check by rebinding. Note that ldap3's mocking - # implementation just compares the string in the objects's userPassword - # field with the password, no support for hashing or OpenLDAP-style - # password-prefixes ("{PLAIN}..." or "{ssha512}..."). - try: - if not conn.rebind(dn, password): - return None - except (LDAPBindError, LDAPPasswordIsMandatoryError): + + # If we use a service connection, test user bind seperately + if current_app.config['LDAP_SERVICE_BIND_DN']: + if not test_user_bind(dn, password): return None + # If we use a user connection, just create the connection normally else: - # When using a LDAP service connection, try bind with separate user connection - if current_app.config['LDAP_SERVICE_BIND_DN']: - server = ldap3.Server(current_app.config["LDAP_SERVICE_URL"]) - try: - conn = connect_and_bind_to_ldap(server, dn, password) - if conn is None: - raise LDAPBindError - except (LDAPBindError, LDAPPasswordIsMandatoryError): - return None - else: - session.clear() - session['user_dn'] = dn - session['user_pw'] = password - try: - conn = ldap.get_connection() - except (LDAPBindError, LDAPPasswordIsMandatoryError): - session.clear() - return None + # ldap.get_connection gets the credentials from the session, so set it here initially + session['user_dn'] = dn + session['user_pw'] = password + if not ldap.get_connection(): + return None - conn.search(conn.user, encode_filter(current_app.config["LDAP_USER_SEARCH_FILTER"])) - if len(conn.entries) != 1: - return None return User.query.get(dn) @bp.route("/logout") @@ -63,10 +37,10 @@ def logout(): session.clear() return resp -def set_session(user, skip_mfa=False, password=''): - session.clear() +def set_session(user, password='', skip_mfa=False): session['user_dn'] = user.dn - if password: + # only save the password if we use a user connection + if password and not current_app.config['LDAP_SERVICE_BIND_DN']: session['user_pw'] = password session['logintime'] = datetime.datetime.now().timestamp() session['_csrf_token'] = secrets.token_hex(128) @@ -97,9 +71,7 @@ def login(): if not user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']): flash('You do not have access to this service') return render_template('login.html', ref=request.values.get('ref')) - # If the configured LDAP bind_dn is empty, connect to LDAP as a user - # Therefore, we save the password for future binds in the session - set_session(user, password=password if not current_app.config['LDAP_SERVICE_BIND_DN'] else '') + set_session(user, password=password) return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) def get_current_user(): diff --git a/uffd/signup/views.py b/uffd/signup/views.py index ce9b139e..ba0f58c6 100644 --- a/uffd/signup/views.py +++ b/uffd/signup/views.py @@ -97,6 +97,6 @@ def signup_confirm_submit(token): return render_template('signup/confirm.html', signup=signup, error=msg) db.session.commit() ldap.session.commit() - set_session(user, skip_mfa=True) + set_session(user, password=request.form['password'], skip_mfa=True) flash('Your account was successfully created') return redirect(url_for('selfservice.index')) -- GitLab