diff --git a/postorius_ldap_membership_management/backends.py b/postorius_ldap_membership_management/backends.py
new file mode 100644
index 0000000000000000000000000000000000000000..36e7e59886a2e804f82838ce52047cb99162daa1
--- /dev/null
+++ b/postorius_ldap_membership_management/backends.py
@@ -0,0 +1,17 @@
+import logging
+
+from django.contrib.auth import login
+from django.contrib.auth.backends import RemoteUserBackend
+
+from postorius_ldap_membership_management.utils import populate_user
+
+logger = logging.getLogger(__name__)
+
+class LdapRemoteUserBackend(RemoteUserBackend):
+	def authenticate(self, request, remote_user=None):
+		logging.info('auth as %s', remote_user)
+		user = populate_user(remote_user)
+		if user is not None:
+			login(request, user, backend='django_auth_ldap.backend.LDAPBackend')
+			logging.debug('loaded data for %s', remote_user)
+		return user
diff --git a/postorius_ldap_membership_management/management/commands/syncldapmemberships.py b/postorius_ldap_membership_management/management/commands/syncldapmemberships.py
index 82f58f4473bce13ca0f24501b466bdbe532fc9e1..9d96d39bee32ef329ce46bb6f564503ca17a4118 100644
--- a/postorius_ldap_membership_management/management/commands/syncldapmemberships.py
+++ b/postorius_ldap_membership_management/management/commands/syncldapmemberships.py
@@ -1,70 +1,32 @@
 import logging
 import ldap
+from urllib.error import HTTPError
 
-from django.contrib.auth import get_user_model, load_backend
+import django.conf
 from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
 from django_auth_ldap.config import LDAPSearch
-import django.conf
+from django_mailman3.lib.mailman import get_mailman_user, get_mailman_client
 
-from urllib.error import HTTPError
-
-from postorius.models import List, MailmanUser
-from postorius.utils import get_mailman_client
-from allauth.account.models import EmailAddress
+from postorius_ldap_membership_management.utils import get_ldap_connection, execute_ldap_search_without_hiding_errors
 
 logger = logging.getLogger(__name__)
 
-def execute_ldap_search_without_hiding_errors(search, connection, filterargs=(), escape=True):
-	'''If an LDAPError occurs during search.execute, it logs the error and returns
-	an empty list of results. This behavor is indistinguishable from a query
-	with no results.'''
-	if not isinstance(search, LDAPSearch):
-		raise NotImplementedError(f'{type(search)} is not supported by postorius_ldap_membership_management')
-	# This is a copy of django_auth_ldap.config.LDAPSearch.execute without the
-	# ldap.LDAPError eating try-except block
-	if escape:
-		filterargs = search._escape_filterargs(filterargs)
-	filterstr = search.filterstr % filterargs
-	results = connection.search_s(search.base_dn, search.scope, filterstr, search.attrlist)
-	return search._process_results(results)
-
 class Command(BaseCommand):
 	can_import_settings = True
 	help = 'Synchronize mailing list memberships from a LDAP server'
 
 	def handle(self, *args, **options):
-		ldap_backend = load_backend('django_auth_ldap.backend.LDAPBackend')
-		conn = ldap_backend.ldap.initialize(ldap_backend.settings.SERVER_URI, bytes_mode=False)
-		for opt, value in ldap_backend.settings.CONNECTION_OPTIONS.items():
-			conn.set_option(opt, value)
-		if ldap_backend.settings.START_TLS:
-			conn.start_tls_s()
-		conn.simple_bind_s(ldap_backend.settings.BIND_DN, ldap_backend.settings.BIND_PASSWORD)
+		mm_client = get_mailman_client()
+		ldap_conn = get_ldap_connection()
 
-		client = get_mailman_client()
-
-		django_users = get_user_model().objects.filter(is_active=True)
 		backref_mapping = {'member': 'members', 'moderator': 'moderators', 'owner': 'owners'}
 		mm_users = {}
 
 		membership_settings = getattr(django.conf.settings, 'LDAP_MEMBERSHIP_SYNC', {})
 
-		# create all django user in mailman
-		for user in django_users:
-			mm_users[user.username] = MailmanUser.objects.get_or_create_from_django(user)
-			if mm_users[user.username].display_name != user.get_full_name():
-				logger.warning("update display_name on {} to {}".format(user.username, user.get_full_name()))
-				mm_users[user.username].display_name = user.get_full_name()
-				mm_users[user.username].save()
-			# set user mail adresses to verified if they match those in ldap
-			user_emails = EmailAddress.objects.filter(user=user)
-			for mail in user_emails:
-				if mail.email == user.email and not mail.verified:
-					logger.warning("update email.verified on user {} for address {} to True".format(user.username, mail.email))
-					mail.verified = True
-					mail.save()
-
-
+		django_users = get_user_model().objects.filter(is_active=True)
+		mm_users = {user.username: get_mailman_user(user) for user in django_users}
 		mailman_id2username = {mm_users[i].user_id: i for i in mm_users}
 
 		for list_name in membership_settings:
@@ -75,11 +37,11 @@ class Command(BaseCommand):
 				ldap_members = execute_ldap_search_without_hiding_errors(LDAPSearch(
 						ldap_setting[membership_type]['dn'], ldap.SCOPE_SUBTREE,
 						ldap_setting[membership_type]['filter'],
-						[ldap_setting[membership_type]['username_attr']]), conn)
+						[ldap_setting[membership_type]['username_attr']]), ldap_conn)
 				ldap_member_names = [list(attr.values())[0][0] for dn, attr in ldap_members]
 
 				# we refetch the mm_list each time because of wired caching problems
-				mm_list = client.get_list(list_name)
+				mm_list = mm_client.get_list(list_name)
 
 				mm_members = getattr(mm_list, backref_mapping[membership_type], [])
 				for mm_member in mm_members:
diff --git a/postorius_ldap_membership_management/management/commands/syncldapusers.py b/postorius_ldap_membership_management/management/commands/syncldapusers.py
new file mode 100644
index 0000000000000000000000000000000000000000..29154f169ffb20803bb4fa62ba69f840f31e08c7
--- /dev/null
+++ b/postorius_ldap_membership_management/management/commands/syncldapusers.py
@@ -0,0 +1,31 @@
+import logging
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+import django.conf
+from django_mailman3.lib.mailman import get_mailman_user
+from allauth.account.models import EmailAddress
+
+from postorius_ldap_membership_management.utils import get_ldap_connection, execute_ldap_search_without_hiding_errors, populate_user
+
+logger = logging.getLogger(__name__)
+
+class Command(BaseCommand):
+	can_import_settings = True
+	help = 'Synchronize users from a LDAP server'
+
+	def handle(self, *args, **options):
+		ldap_conn = get_ldap_connection()
+		results = execute_ldap_search_without_hiding_errors(django.conf.settings.AUTH_LDAP_USER_SEARCH_ALL_NAME, ldap_conn)
+		ldap_usernames = [list(attr.values())[0][0] for dn, attr in results]
+		for username in ldap_usernames:
+			logger.warning(f'creating or updating {username}')
+			# populate_user ignores all errors
+			populate_user(username)
+
+		users = get_user_model().objects.filter(is_active=True)
+		for user in users:
+			if not user.username in ldap_usernames:
+				logger.warning(f'deactivating {user.username}')
+				user.is_active = False
+				user.save()
diff --git a/postorius_ldap_membership_management/middleware.py b/postorius_ldap_membership_management/middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..8ba7eb889a3ddd5875806fb8c3848c4c64266616
--- /dev/null
+++ b/postorius_ldap_membership_management/middleware.py
@@ -0,0 +1,46 @@
+# Based on https://github.com/labd/django-session-timeout
+# Copyright (c) 2017 Michael van Tellingen
+
+import time
+
+from django.conf import settings
+from django.contrib.auth.views import redirect_to_login
+from django.shortcuts import redirect
+
+try:
+	from django.utils.deprecation import MiddlewareMixin
+except ImportError:
+	MiddlewareMixin = object
+
+SESSION_TIMEOUT_KEY = "_session_init_timestamp_"
+
+class SessionTimeoutMiddleware(MiddlewareMixin):
+	def process_request(self, request):
+		if not hasattr(request, "session") or request.session.is_empty():
+			return
+
+		init_time = request.session.setdefault(SESSION_TIMEOUT_KEY, time.time())
+
+		expire_seconds = getattr(
+			settings, "SESSION_EXPIRE_SECONDS", settings.SESSION_COOKIE_AGE
+		)
+
+		session_is_expired = time.time() - init_time > expire_seconds
+
+		if session_is_expired:
+			request.session.flush()
+			redirect_url = getattr(settings, "SESSION_TIMEOUT_REDIRECT", None)
+			if redirect_url:
+				return redirect(redirect_url)
+			else:
+				return redirect_to_login(next=request.path)
+
+		expire_since_last_activity = getattr(
+			settings, "SESSION_EXPIRE_AFTER_LAST_ACTIVITY", False
+		)
+		grace_period = getattr(
+			settings, "SESSION_EXPIRE_AFTER_LAST_ACTIVITY_GRACE_PERIOD", 1
+		)
+
+		if expire_since_last_activity and time.time() - init_time > grace_period:
+			request.session[SESSION_TIMEOUT_KEY] = time.time()
diff --git a/postorius_ldap_membership_management/models.py b/postorius_ldap_membership_management/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa8de5de4bf3b3779535045062ec972d8d4d8d1c
--- /dev/null
+++ b/postorius_ldap_membership_management/models.py
@@ -0,0 +1,13 @@
+import django_mailman3.forms
+
+# We cannot allow users to change their username, because we use the username
+# to connect LDAP users do Django users. Since there are no other options, we
+# monkey-patch the UserProfileForm.
+
+# With disabled set to True, client-side changes are ignored. So we don't
+# need any more validation.
+django_mailman3.forms.UserProfileForm.declared_fields['username'].disabled = True
+django_mailman3.forms.UserProfileForm.declared_fields['first_name'].disabled = True
+django_mailman3.forms.UserProfileForm.declared_fields['first_name'].required = False
+django_mailman3.forms.UserProfileForm.declared_fields['last_name'].disabled = True
+django_mailman3.forms.UserProfileForm.declared_fields['last_name'].required = False
diff --git a/postorius_ldap_membership_management/utils.py b/postorius_ldap_membership_management/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..95e1ab8594c91199fbb8b52cfcea3d7de94db426
--- /dev/null
+++ b/postorius_ldap_membership_management/utils.py
@@ -0,0 +1,84 @@
+import logging
+
+import django.conf
+from django.contrib.auth import load_backend
+from allauth.account.models import EmailAddress
+from django_auth_ldap.config import LDAPSearch
+from django_mailman3.lib.mailman import get_mailman_user, sync_email_addresses, update_preferred_address
+
+logger = logging.getLogger(__name__)
+
+ldap_backend = load_backend('django_auth_ldap.backend.LDAPBackend')
+
+def get_ldap_connection():
+	conn = ldap_backend.ldap.initialize(ldap_backend.settings.SERVER_URI, bytes_mode=False)
+	for opt, value in ldap_backend.settings.CONNECTION_OPTIONS.items():
+		conn.set_option(opt, value)
+	if ldap_backend.settings.START_TLS:
+		conn.start_tls_s()
+	conn.simple_bind_s(ldap_backend.settings.BIND_DN, ldap_backend.settings.BIND_PASSWORD)
+	return conn
+
+def execute_ldap_search_without_hiding_errors(search, connection, filterargs=(), escape=True):
+	'''If an LDAPError occurs during search.execute, it logs the error and returns
+	an empty list of results. This behavor is indistinguishable from a query
+	with no results.'''
+	if not isinstance(search, LDAPSearch):
+		raise NotImplementedError(f'{type(search)} is not supported by postorius_ldap_membership_management')
+	# This is a copy of django_auth_ldap.config.LDAPSearch.execute without the
+	# ldap.LDAPError eating try-except block
+	if escape:
+		filterargs = search._escape_filterargs(filterargs)
+	filterstr = search.filterstr % filterargs
+	results = connection.search_s(search.base_dn, search.scope, filterstr, search.attrlist)
+	return search._process_results(results)
+
+def populate_user(username):
+	'''Create or update user from LDAP, including it's django-allauth email
+	addresses and Mailman backend user'''
+	# Caution: If a user has no verified primary email address (an EmailAddress
+	# object with verified=True and primary=True), django-allauth allows the user
+	# to set his primary email address and also User.email to any unverified
+	# address. This is a security problem, because Postorius and Hyperkitty use
+	# User.email to lookup the corresponding Mailman backend user and thus for
+	# permission checking. This causes a race-condition in the following code
+	# that we can't do anything about. ldap_backend.populate_user creates (and
+	# saves) a new user. We create the EmailAddress objects afterwards. If this
+	# fails or does not happen for some reason, we end up with a user without a
+	# primary email address.
+	user = ldap_backend.populate_user(username)
+	if user is None:
+		# ldap_backend.populate_user hides LDAP errors and returns None if
+		# they occur. It also returns None if the user is not found in the LDAP
+		# directory.
+		return None
+	if 'last_name' not in django.conf.settings.AUTH_LDAP_USER_ATTR_MAP:
+		user.last_name = ''
+
+	primary_changed = False
+
+	user_email_objs = EmailAddress.objects.filter(user=user)
+	primary_email_obj = ([obj for obj in user_email_objs if obj.primary] or [None])[0]
+	ldap_email = user.ldap_user.attrs[django.conf.settings.AUTH_LDAP_USER_ATTR_MAP['email']][0]
+	ldap_email_obj = ([obj for obj in user_email_objs if obj.email == ldap_email] or [None])[0]
+	if ldap_email_obj is None:
+		ldap_email_obj = EmailAddress(user=user, email=ldap_email, verified=False, primary=False)
+	ldap_email_obj.verified = True
+	if primary_email_obj is None:
+		ldap_email_obj.primary = True
+		primary_email_obj = ldap_email_obj
+		primary_changed = True
+	ldap_email_obj.save()
+
+	# ldap_backend.populate_user overwrites user.email with the address from LDAP
+	user.email = primary_email_obj.email
+	user.save()
+
+	sync_email_addresses(user)
+	if primary_changed:
+		update_preferred_address(user, primary_email_obj)
+	mm_user = get_mailman_user(user)
+	if mm_user.display_name != user.get_full_name():
+		mm_user.display_name = user.get_full_name()
+		mm_user.save()
+	return user