Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • uffd/uffd
  • rixx/uffd
  • thies/uffd
  • leona/uffd
  • strifel/uffd
  • thies/uffd-2
6 results
Select Git revision
Show changes
Commits on Source (13)
......@@ -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.
......
......@@ -135,3 +135,6 @@ class TestSession(UffdTestCase):
class TestSessionOL(TestSession):
use_openldap = True
class TestSessionOLUser(TestSessionOL):
use_userconnection = True
......@@ -25,6 +25,7 @@ def db_flush():
class UffdTestCase(unittest.TestCase):
use_openldap = False
use_userconnection = False
def setUp(self):
self.dir = tempfile.mkdtemp()
......@@ -43,6 +44,9 @@ class UffdTestCase(unittest.TestCase):
self.skipTest('OPENLDAP_TESTING not set')
config['LDAP_SERVICE_MOCK'] = False
config['LDAP_SERVICE_URL'] = 'ldap://localhost'
if self.use_userconnection:
config['LDAP_SERVICE_BIND_DN'] = None
else:
config['LDAP_SERVICE_BIND_DN'] = 'cn=uffd,ou=system,dc=example,dc=com'
config['LDAP_SERVICE_BIND_PASSWORD'] = 'uffd-ldap-password'
os.system("ldapdelete -c -D 'cn=uffd,ou=system,dc=example,dc=com' -w 'uffd-ldap-password' -H 'ldap://localhost' -f ldap_server_entries_cleanup.ldif > /dev/null 2>&1")
......
......@@ -2,7 +2,7 @@ import os
import secrets
import sys
from flask import Flask, redirect, url_for
from flask import Flask, redirect, url_for, request
from werkzeug.routing import IntegerConverter
sys.path.append('deps/ldapalchemy')
......@@ -42,17 +42,33 @@ def create_app(test_config=None): # pylint: disable=too-many-locals
pass
db.init_app(app)
# pylint: disable=C0415
from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services, signup, invite
from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services
# 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 + invite.bp:
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)
if app.config['LDAP_SERVICE_BIND_DN'] or app.config.get('LDAP_SERVICE_MOCK', False):
# pylint: disable=C0415
from uffd import signup, invite
# pylint: enable=C0415
for i in signup.bp + invite.bp:
app.register_blueprint(i)
else:
app.config['ENABLE_PASSWORDRESET'] = False
@app.route("/")
def index(): #pylint: disable=unused-variable
return redirect(url_for('selfservice.index'))
@app.teardown_request
def close_connection(exception): #pylint: disable=unused-variable,unused-argument
if hasattr(request, "ldap_connection"):
request.ldap_connection.unbind()
return app
def init_db(app):
......
......@@ -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
......
from flask import current_app, request, abort
from flask import current_app, request, abort, session
import ldap3
from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError, LDAPInvalidDnError
from ldapalchemy import LDAPMapper, LDAPCommitError # pylint: disable=unused-import
from ldapalchemy.model import Query
from ldapalchemy.core import encode_filter
class FlaskQuery(Query):
def get_or_404(self, dn):
......@@ -18,9 +21,49 @@ class FlaskQuery(Query):
abort(404)
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"]))
lazy_entries = conn.entries
conn.unbind()
return len(lazy_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)
if _connection.closed:
_connection.open(read_server_info=False)
if current_app.config["LDAP_SERVICE_USE_STARTTLS"]:
_connection.start_tls(read_server_info=False)
if not _connection.bind(read_server_info=True):
_connection.unbind()
raise LDAPBindError
return _connection
class FlaskLDAPMapper(LDAPMapper):
def __init__(self):
super().__init__()
class Model(self.Model):
query_class = FlaskQuery
......@@ -48,9 +91,20 @@ 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)
auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND if current_app.config["LDAP_SERVICE_USE_STARTTLS"] else True
request.ldap_connection = ldap3.Connection(server, current_app.config["LDAP_SERVICE_BIND_DN"],
current_app.config["LDAP_SERVICE_BIND_PASSWORD"], auto_bind=auto_bind)
# 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"]
bind_pw = current_app.config["LDAP_SERVICE_BIND_PASSWORD"]
else:
bind_dn = session['user_dn']
bind_pw = session['user_pw']
try:
request.ldap_connection = connect_and_bind_to_ldap(server, bind_dn, bind_pw)
except (LDAPBindError, LDAPPasswordIsMandatoryError):
return None
return request.ldap_connection
ldap = FlaskLDAPMapper()
......@@ -4,7 +4,7 @@ import smtplib
from email.message import EmailMessage
import email.utils
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, session
from uffd.navbar import register_navbar
from uffd.csrf import csrf_protect
......@@ -29,6 +29,7 @@ def index():
@csrf_protect(blueprint=bp)
@login_required()
def update():
password_changed = False
user = get_current_user()
if request.values['displayname'] != user.displayname:
if user.set_displayname(request.values['displayname']):
......@@ -41,12 +42,16 @@ def update():
else:
if user.set_password(request.values['password1']):
flash('Password changed.')
password_changed = True
else:
flash('Password could not be set.')
if request.values['mail'] != user.mail:
send_mail_verification(user.loginname, request.values['mail'])
flash('We sent you an email, please verify your mail address.')
ldap.session.commit()
# When using a user_connection, update the connection on password-change
if password_changed and not current_app.config['LDAP_SERVICE_BIND_DN']:
session['user_pw'] = request.values['password1']
return redirect(url_for('selfservice.index'))
@bp.route("/passwordreset", methods=(['GET', 'POST']))
......
......@@ -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>
......
......@@ -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
from uffd.ldap import ldap, test_user_bind, LDAPInvalidDnError
from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay
bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
......@@ -18,29 +14,26 @@ 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'] or current_app.config.get('LDAP_SERVICE_MOCK', False):
if not test_user_bind(dn, password):
return None
# If we use a user connection, just create the connection normally
else:
server = ldap3.Server(current_app.config["LDAP_SERVICE_URL"], get_info=ldap3.ALL)
auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND if current_app.config["LDAP_SERVICE_USE_STARTTLS"] else True
try:
conn = ldap3.Connection(server, dn, password, auto_bind=auto_bind)
except (LDAPBindError, LDAPPasswordIsMandatoryError):
# 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():
session.clear()
return None
conn.search(conn.user, encode_filter(current_app.config["LDAP_USER_SEARCH_FILTER"]))
if len(conn.entries) != 1:
try:
user = User.query.get(dn)
if user:
return user
except LDAPInvalidDnError:
return None
return User.query.get(dn)
@bp.route("/logout")
def logout():
......@@ -50,9 +43,12 @@ def logout():
session.clear()
return resp
def set_session(user, skip_mfa=False):
def set_session(user, password='', skip_mfa=False):
session.clear()
session['user_dn'] = user.dn
# 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)
if skip_mfa:
......@@ -82,7 +78,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'))
set_session(user)
set_session(user, password=password)
return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index'))))
def get_current_user():
......
......@@ -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'))