From bbd251f73930c90eff6055c90be5f31285225368 Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 24 Mar 2024 18:39:30 +0100
Subject: [PATCH] Revokable server-side sessions

---
 README.md                                     |   1 +
 debian/control                                |   1 +
 setup.py                                      |   1 +
 tests/models/test_session.py                  |  48 ++++++
 .../87cb93a329bf_server_side_sessions.py      |  31 ++++
 uffd/models/__init__.py                       |   2 +-
 uffd/models/session.py                        |  81 ++++++++-
 uffd/models/user.py                           |   2 +
 uffd/templates/selfservice/self.html          |  49 ++++++
 uffd/templates/user/show.html                 |   7 +
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 41508 -> 42539 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  | 161 ++++++++++++------
 uffd/views/mfa.py                             |  12 +-
 uffd/views/selfservice.py                     |  12 +-
 uffd/views/session.py                         |  45 +++--
 uffd/views/user.py                            |   9 +
 16 files changed, 392 insertions(+), 70 deletions(-)
 create mode 100644 tests/models/test_session.py
 create mode 100644 uffd/migrations/versions/87cb93a329bf_server_side_sessions.py

diff --git a/README.md b/README.md
index 2c51d40..984acc8 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Please note that we refer to Debian packages here and **not** pip packages.
 - python3-argon2
 - python3-itsdangerous (also a dependency of python3-flask)
 - python3-mysqldb or python3-pymysql for MariaDB support
+- python3-ua-parser (optional, better user agent parsing)
 
 Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Bookworm, Bullseye or Buster.
 For development, you can also use virtualenv with the supplied `requirements.txt`.
diff --git a/debian/control b/debian/control
index a9a87dd..e206c48 100644
--- a/debian/control
+++ b/debian/control
@@ -34,4 +34,5 @@ Recommends:
  nginx,
  python3-mysqldb,
  python3-prometheus-client,
+ python3-ua-parser,
 Description: Web-based user management and single sign-on software
diff --git a/setup.py b/setup.py
index 189b301..31db92f 100644
--- a/setup.py
+++ b/setup.py
@@ -44,6 +44,7 @@ setup(
 		'argon2-cffi==18.3.0',
 		'itsdangerous==0.24',
 		'prometheus-client==0.9',
+		'ua-parser==0.8.0',
 
 		# The main dependencies on their own lead to version collisions and pip is
 		# not very good at resolving them, so we pin the versions from Debian Buster
diff --git a/tests/models/test_session.py b/tests/models/test_session.py
new file mode 100644
index 0000000..291c63a
--- /dev/null
+++ b/tests/models/test_session.py
@@ -0,0 +1,48 @@
+import unittest
+import datetime
+
+from uffd.database import db
+from uffd.models.session import Session, USER_AGENT_PARSER_SUPPORTED
+
+from tests.utils import UffdTestCase
+
+class TestSession(UffdTestCase):
+	def test_expire(self):
+		self.app.config['SESSION_LIFETIME_SECONDS'] = 100
+		self.app.config['PERMANENT_SESSION_LIFETIME'] = 10
+		user = self.get_user()
+		def make_session(created_age, last_used_age):
+			return Session(
+				user=user,
+				created=datetime.datetime.utcnow() - datetime.timedelta(seconds=created_age),
+				last_used=datetime.datetime.utcnow() - datetime.timedelta(seconds=last_used_age),
+			)
+		session1 = Session(user=user)
+		self.assertFalse(session1.expired)
+		session2 = make_session(0, 0)
+		self.assertFalse(session2.expired)
+		session3 = make_session(50, 5)
+		self.assertFalse(session3.expired)
+		session4 = make_session(50, 15)
+		self.assertTrue(session4.expired)
+		session5 = make_session(105, 5)
+		self.assertTrue(session5.expired)
+		session6 = make_session(105, 15)
+		self.assertTrue(session6.expired)
+		db.session.add_all([session1, session2, session3, session4, session5, session6])
+		db.session.commit()
+		self.assertEqual(set(Session.query.filter_by(expired=False).all()), {session1, session2, session3})
+		self.assertEqual(set(Session.query.filter_by(expired=True).all()), {session4, session5, session6})
+
+	def test_useragent_ua_parser(self):
+		if not USER_AGENT_PARSER_SUPPORTED:
+			self.skipTest('ua_parser not available')
+		session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0')
+		self.assertEqual(session.user_agent_browser, 'Firefox')
+		self.assertEqual(session.user_agent_platform, 'Windows')
+
+	def test_useragent_no_ua_parser(self):
+		session = Session(user_agent='Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0')
+		session.DISABLE_USER_AGENT_PARSER = True
+		self.assertEqual(session.user_agent_browser, 'Firefox')
+		self.assertEqual(session.user_agent_platform, 'Windows')
diff --git a/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py b/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py
new file mode 100644
index 0000000..e8fea90
--- /dev/null
+++ b/uffd/migrations/versions/87cb93a329bf_server_side_sessions.py
@@ -0,0 +1,31 @@
+"""Server-side sessions
+
+Revision ID: 87cb93a329bf
+Revises: 01fdd7820f29
+Create Date: 2024-03-23 23:57:44.019456
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '87cb93a329bf'
+down_revision = '01fdd7820f29'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	op.create_table('session',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('secret', sa.Text(), nullable=True),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('created', sa.DateTime(), nullable=False),
+		sa.Column('last_used', sa.DateTime(), nullable=False),
+		sa.Column('user_agent', sa.Text(), nullable=False),
+		sa.Column('ip_address', sa.Text(), nullable=True),
+		sa.Column('mfa_done', sa.Boolean(create_constraint=True), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_session_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_session'))
+	)
+
+def downgrade():
+	op.drop_table('session')
diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py
index 756a972..2dcf9a7 100644
--- a/uffd/models/__init__.py
+++ b/uffd/models/__init__.py
@@ -6,7 +6,7 @@ from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Gran
 from .role import Role, RoleGroup, RoleGroupMap
 from .selfservice import PasswordToken
 from .service import RemailerMode, Service, ServiceUser, get_services
-from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation
+from .session import Session, DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation
 from .signup import Signup
 from .user import User, UserEmail, Group, IDAllocator, IDRangeExhaustedError, IDAlreadyAllocatedError
 from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay
diff --git a/uffd/models/session.py b/uffd/models/session.py
index 8e5f847..842d4ec 100644
--- a/uffd/models/session.py
+++ b/uffd/models/session.py
@@ -2,13 +2,92 @@ import datetime
 import secrets
 import enum
 
-from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum
+from flask import current_app
+from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Enum, Text, Boolean
 from sqlalchemy.orm import relationship
 from sqlalchemy.ext.hybrid import hybrid_property
+from flask_babel import gettext as _
+
+try:
+	from ua_parser import user_agent_parser
+	USER_AGENT_PARSER_SUPPORTED = True
+except ImportError:
+	USER_AGENT_PARSER_SUPPORTED = False
 
 from uffd.database import db
 from uffd.utils import token_typeable
 from uffd.tasks import cleanup_task
+from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash
+
+@cleanup_task.delete_by_attribute('expired')
+class Session(db.Model):
+	__tablename__ = 'session'
+
+	id = Column(Integer(), primary_key=True, autoincrement=True)
+	_secret = Column('secret', Text)
+	secret = PasswordHashAttribute('_secret', HighEntropyPasswordHash)
+
+	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
+	user = relationship('User', back_populates='sessions')
+
+	created = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
+	last_used = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
+	user_agent = Column(Text, nullable=False, default='')
+	ip_address = Column(Text)
+
+	mfa_done = Column(Boolean(create_constraint=True), default=False, nullable=False)
+
+	@hybrid_property
+	def expired(self):
+		if self.created is None or self.last_used is None:
+			return False
+		if self.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']):
+			return True
+		if self.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime:
+			return True
+		return False
+
+	@expired.expression
+	def expired(cls): # pylint: disable=no-self-argument
+		return db.or_(
+			cls.created < datetime.datetime.utcnow() - datetime.timedelta(seconds=current_app.config['SESSION_LIFETIME_SECONDS']),
+			cls.last_used < datetime.datetime.utcnow() - current_app.permanent_session_lifetime,
+		)
+
+	@property
+	def user_agent_browser(self):
+		# pylint: disable=too-many-return-statements
+		if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False):
+			family = user_agent_parser.ParseUserAgent(self.user_agent)['family']
+			return family if family != 'Other' else _('Unknown')
+
+		if ' OPR/' in self.user_agent:
+			return 'Opera'
+		if ' Edg/' in self.user_agent:
+			return 'Microsoft Edge'
+		if ' Safari/' in self.user_agent and ' Chrome/' not in self.user_agent:
+			return 'Safari'
+		if ' Chrome/' in self.user_agent:
+			return 'Chrome'
+		if ' Firefox/' in self.user_agent:
+			return 'Firefox'
+		return _('Unknown')
+
+	@property
+	def user_agent_platform(self):
+		if USER_AGENT_PARSER_SUPPORTED and not getattr(self, 'DISABLE_USER_AGENT_PARSER', False):
+			family = user_agent_parser.ParseOS(self.user_agent)['family']
+			return family if family != 'Other' else _('Unknown')
+
+		sysinfo = ([''] + self.user_agent.split('(', 1))[-1].split(')', 0)[0]
+		platforms = [
+			'Android', 'Linux', 'OpenBSD', 'FreeBSD', 'NetBSD', 'Windows', 'iPhone',
+			'iPad', 'Macintosh',
+		]
+		for platform in platforms:
+			if platform in sysinfo:
+				return platform
+		return _('Unknown')
 
 # Device login provides a convenient and secure way to log into SSO-enabled
 # services on a secondary device without entering the user password or
diff --git a/uffd/models/user.py b/uffd/models/user.py
index 48bd4cd..70cba25 100644
--- a/uffd/models/user.py
+++ b/uffd/models/user.py
@@ -174,6 +174,8 @@ class User(db.Model):
 
 	service_users = relationship('ServiceUser', viewonly=True)
 
+	sessions = relationship('Session', back_populates='user', cascade='all, delete-orphan')
+
 	def __init__(self, primary_email_address=None, **kwargs):
 		super().__init__(**kwargs)
 		if primary_email_address is not None:
diff --git a/uffd/templates/selfservice/self.html b/uffd/templates/selfservice/self.html
index 548791d..cc977f8 100644
--- a/uffd/templates/selfservice/self.html
+++ b/uffd/templates/selfservice/self.html
@@ -173,6 +173,55 @@
 
 <hr>
 
+<div class="row mt-3">
+	<div class="col-12 col-md-5">
+		<h5>{{_("Active Sessions")}}</h5>
+		<p>{{_("Your active login sessions on this device and other devices.")}}</p>
+		<p>{{_("Revoke a session to log yourself out on another device. Note that this is limited to the Single-Sign-On session and <b>does not affect login sessions on services.</b>")}}</p>
+	</div>
+	<div class="col-12 col-md-7">
+		<table class="table">
+			<thead>
+				<tr>
+					<th scope="col">{{_("Last used")}}</th>
+					<th scope="col">{{_("Device")}}</th>
+					<th scope="col"></th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>{{_("Just now")}}</td>
+					<td>{{ request.session.user_agent_browser }} on {{ request.session.user_agent_platform }} ({{ request.session.ip_address }})</td>
+					<td></td>
+				</tr>
+				{% for session in user.sessions|sort(attribute='last_used', reverse=True) if not session.expired and session != request.session %}
+				<tr>
+					<td>
+						{% set last_used_rel = session.last_used - datetime.utcnow() %}
+						{% if -last_used_rel.total_seconds() <= 60 %}
+						{{_("Just now")}}
+						{% else %}
+						{{ last_used_rel|timedeltaformat(add_direction=True, granularity='minute') }}
+						{% endif %}
+					</td>
+					<td>{{ session.user_agent_browser }} on {{ session.user_agent_platform }} ({{ session.ip_address }})</td>
+					<td>
+						{% if session != request.session %}
+						<form action="{{ url_for("selfservice.revoke_session", session_id=session.id) }}" method="POST" onsubmit='return confirm({{_("Are you sure?")|tojson|e}});'>
+							<button type="submit" class="btn btn-sm btn-danger float-right">{{_("Revoke")}}</button>
+						</form>
+						{% endif %}
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+
+	</div>
+</div>
+
+<hr>
+
 <div class="row mt-3">
 	<div class="col-12 col-md-5">
 		<h5>{{_("Roles")}}</h5>
diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html
index ed4b2bd..04a0d68 100644
--- a/uffd/templates/user/show.html
+++ b/uffd/templates/user/show.html
@@ -192,6 +192,13 @@
 				</p>
 				<a href="{{ url_for("mfa.admin_disable", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Reset 2FA")}}</a>
 			</div>
+
+			<div class="form-group col">
+				<div class="mb-1">{{_("Sessions")}}</div>
+				<p>{{ _("%(session_count)d active sessions", session_count=user.sessions|rejectattr('expired')|list|length) }}</p>
+				<a href="{{ url_for("user.revoke_sessions", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-secondary">{{_("Revoke all sessions")}}</a>
+			</div>
+
 			{% endif %}
 
 		</div>
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 68f53c06570d8c2f651b2e70a4884e8674ca69ba..239b32b84951bc3fb8c285fb4ac17427fbf5f79e 100644
GIT binary patch
delta 7784
zcmZ2-glY8|ruutAEK?a67#LPFGBC(6Fff$xf_MmgMv8%fpMinltP}%-Fara_T`2|z
zMFs|jk5UW_Yzzzx!qN;3J`4;DQql|zJPZsB>Cy}gTp)9x{Ca5y1}z2#h90Q+c4-C%
zkQIld85o2Z80r}=NJ9*M45hzIGcZUoFfee-Ffgz)FfgdeFffQPFfi!JFfed1FfcgF
zKpf;F!@$7Jz`zhA!@y9@z`&3p198|Bs6IAXh(1191_mVt1_otW1_n6>1_n1-1_pZu
z28L8w1_mpJdIpB2vJ4D`3=9kppbCQI7#MsR7#IrWATHf6$G{K`3IRC=hUE+l44U!`
z4DJjJ3>W1Y7-|_97$g-S7ED)QU=U|uVAutvFDWoEh%qoQyi#CbFkxU|U{r*7%uJDi
z!Ht1|AyAQlL9(8KfnkOsMByeyh|f+cGB8*$FfiOygaoCK5+tZ)l^7UQ7#J8-pnOjy
zi248}h>sGK7#LU?7#J2PF)+k3Ffc4rVqoB8U|?WThIoWe86q#E%)r3Hz`&rS%)n4@
z$iTp$t_(3KQkj84mVtpGQ<;H5l!1YvU73MFnt_2~t}+9IAOi!#PAGj^84?wDl_3s)
z237Z084{I}Dhv#K3=9mqDv&6$QGs~GLj|HQ7|M@PVW<ZuqI4CA3-eSU7T2gi64NB8
z0rOQLak&~Qen15hB4?reD=G{Ok_-$C4^<c#G#MBeeycznqN>WkV9CJ1V5tgGU#to-
zw@MX~R@znTAud`8mDr>Taln361_pHo28OGukU0IT3W+;WHHf@{8pMZgP&!nNfkA+Q
zfgwc=oT?d0)gU3+p~k>q4T=giNJu?WV_*P<_)j&6gSG3`Ar`o*LoA3@XJF80U|^_F
zhZwLzoq<7tfq~(eIwbC%t3wQYs}6~CO$|sc$<$zA$OI)?DF3zw#AA;&AU=Pi0de4G
z4M<e8XhNc<UPP0Dfti7UK|zy&K^K&`G$C;wqzTC`d72Oldo>|ZbW#&y@O3Ev0hE5H
z2?_c?nh*=sv>+j*tHr>e%)r241EmwR7#JKF7#Lc#7#OaC^8ZmS28Id-28P+%3=9()
z7#Ku!AP!ij12O234#a?yIuMt?)qxoN7s}_+g=9Y&T?U5D3=9nRx)61rbs-_ctOp5d
zZaqjytLj10hOHhbEz~nG1nNP8CQc9HgM2+mNjMX#aGM?^?oL6)AL>CI_D>HIgsl1y
z0|oUV4pY^KBrX$u1_liV28J+wNVculhnTZO9};!D^%)r0LHYl;KEyy~0|o|d1_lN$
z14s~?89?%{y#XZdJq;lF)*mV!VgN}SaRv+wstgPa^#%|hE;WEe-ChF*1{VefhC2q3
z#H?rt2}w^wh<Uk&3=H+4vbxw1qM*(YV$d=}NGd;L$iSe-z`*dxkbyy-fq_BL2;yTy
zBS?_i8bK0mxDmtyMNs)lBZ$vijUWzOX9Nmr28Q!S5OePsF)&PFU|{%U#8A)B0V-&W
zA*o%!1foID1QL|~CXhIcGl3+|G!sZcGQ|WE$Md25^(GL9?J|Lc*fA4GF1la>2{8s!
z28K`u1_oABi2i6(NTN?Ot%u~-EK`UJYfT{rb(uoa#57Zg{Bl!B(CspXSa8o25+W~5
zA#uxL22rPL2GQqW#=zhRD&x%<7#bNE7$%!R3NTS~h|jgm85lM)Ffi<|H;2S^p#{W;
zRTdEb3JZuq8!aG->yQN`abB~4BqC6;1}f>qEg=qew1jxT*AkMrqAVF0(is>SW?4c)
zipdJ1Psj?QUegK^vh}7`5DRRrAVD5z1xZ8+RtyaJpn}MXfuW6of#HP}B>(4IL+Dy-
zh(o)rAqG!@im$eYn77lKfx#A3Yg#if7=fyD8wLgm1_lN@8%SLeZUgpUJwv7qq<XBf
zfh3~!HV_wYvw;NlF&l`(F4#a)^?e&iqWfn9X;?_wLK2&cEhGwZp>%^S#JtJ23=GRa
zAz=$~n4=xU+)z7^N9q|E;-Lb$c93jQZU?C}YV9BvZMTD1aLEpmn10(q3<k9#A{ZDL
z*zF-fnqUv{VU9f{Dq5lRBzs8Q&$ow!*cN+;2lv`DFbIP3|5>QSeS2__Fubsb`1mK(
z01gKRh8YYD45AJY1J*f!T+YC--2tNhr~|};YYvd8c<lgjAd@2`L^vHGsb0nrBCqZU
zX|mZlGBDJGir#KV1_qE%SezIbb~7+A#5yrBOairRoFN9SafT$8%}{!`GsMEf&XC&d
zhBE_0E2!XdVPKfRz`!uU1yVjJx<Z27+LeJ}F#`jGzbga776t}}&#nv%0SpWbE8H0B
z8EhCB7=F4jFc>f}FqpYRQhB;NBnl?FLz-Hr-5~}FdqBzuT@Oh1^7DXHCKEg$+4G_Y
zB#ORzK%(-O2c*DZ@MK_k%fP@O=E=Zd4r&p3LCPB&uX;#95ak8&VVoBvNYlI^7M6QK
zqGF;KBqSDiF)-vYFfc6lVqow98R*Tx;Kjhe5bq7~=_+pqhEfIwhJD@;^Gtjo`fYp|
z80;7r7`%NT=6Cr((#Y(3ABam=K<RZpki@jb2U3X~@PYX3kq;#Gv-v{OfT}Mf8|nE%
z9OmE)aj?5D#K(zH@p4~C8mRMylnWDm85p8KjaOetqqbhh55nm1gT(n0KL&<W1_p+5
ze+C9KP<!8>fguJ|EeAj<r@{aRhGU>MVE`l~QUW2Z*~@_p3?-m`KoA3iF#`ibe-Omy
zhl3zdbtwqq0FhuwbsZASz@P@I|I33Rsc~K~q@Z{m%)sEvz`!6E0;!BrLLfoc6#{7&
zoD6{k@zW4UT8R#Y6j-gH4B+Z^ZYZQAJr>Hq5Xr#6@FkRiA(4TB!7mI#ZwO;xaAsg&
z5DEvGU(di07R~@pgf-y|3`an%+;B+qxh?{d&F(}%g7#GeBx+b9A+$gwB(cdvLZU=5
z5|YidA{iK%7#J8#BOz(P0!lkTX%DEnfJg=g9#H;|j)W8l8IcSO{frC@6_Jp*<%otP
zDye8lkm*H3EDDT<L`hUMBr$D|hB)X%G{nJIq8UIH9>b$(h=U|zAZb831`<+cF_2v3
z7sJ3%4{A2&$AII4p)&@O7&pW~64AC8hyiC}AQoMZfjH<z3<JX%1_p*dF_0qoWGuv|
zUt%Hp{=`DkiclQH;dXJ5s7Q>1q?O%q3=DA$3=B;14E5laOJY1kL3=!;V0jqNz>v(q
z!0;`efnf~;14C2-L?e45149ugE)ya3`@uv=5&ba{QnK+RL8@itBuJyxISJB~t50HJ
z2xeeln3DuaLm!eL4&_OP*8fV$kRUTnhWI!;8B$4<B|}nkLo&pNTaqCmu_qbgptH%4
zd@Pc}z!1j3z@V4{(N~cIiHfEah<OuIAZcZG3M3aSNP)B)9@M8mf`~O0LTjW#eB_-9
zNdsxAkRY!}h2-;|REWWoQy~T{gsMN53JtkbNVfY1rCHM;4iZU&I8-hTl7_U?ARehV
zNrMEDYZ}DDB&foYG>CzXQ2w+uhyxZw`K!_(4&9yxDY`GEL4uY!9a3b=r$a(4Jso0h
zemX>5V>%?UZi3MD4Exd{ad#>m(%5{M4#{o;8IUqsD+3az9vP63$jE^BuqFeNYq~Qa
z4xOC=N!1%N7#RFOC0quiI#$Sp1i57<#N5hEh{Gpmf=bkS28MN+3=CGFmI_#afq^57
zfk6k<E6#%WI3x>Ff<<RR64Bf&NG|!91?m5P&w|8pX*MK?8?qrj@6CodcxpBz`>o7|
zICxJsq|P~$&A<Tae==OihGf5Q*^rV;Hiv;B1XNk%K!R>T4#a{@IS>Q(=0FTSn*$jq
zc$fpRP%0M^r%JgXpD{2P=R%tGLAj6;uPT>;;RmR4%4J}vWME*}p2xsYznX!8!7v|E
zkKfIQ#0`G|r0kb2fVe!U0OF9;0!U&iFMtGfV*vxhAqED9MFkLtr4~ZuOA8@US6>Lp
zW^)T6LB6>V;=pr-5OePrGSq{E`V&-xd=bROwop2?2$E=eiy(2n4@#dcf*5$G2oe(C
ziy(CecQK?uvMYw<j?7|6;_fSkG&oilLmc|27~)Z$l6puhRiXqU5LN>5X;KNqCnY72
zd|Oun$u`X;kZjsh0&(biDF1p1q(J&r0x8+FN+F4`vlJ3iH%cM;f0jb*VJd@^H+=PF
zkW}wd21%X8Wsusgw+xa9&y+zD)z30Wkg}CSvYARb#6X8~h`eVx#GJ5lh(lV-AtAQ4
z91;RY${83gf<{Qn85s64FfeScfV3~_iz*?BZb~J@=Z7jGiS0@yq*;C+$``4E$jetj
zvZG-Y#Akt3kSHjsf)u%JRge%nR0XM|j#okATBRD2>dmVm>aDB64y<SJuZH*_t{S2+
zyBbnU)m204^J!4|{nd~-JyQ*F@W*OM{+6$S=(DI{U<e20ni>X%ItB)Y#Wj#}MX8p7
zVI^n`sFs1@00RTV<64LZm(+nAT+hI;z77(Uuj?QNeW`<J1oh29y<fR{NcQxthcq@p
zX$Ca9wY46Sn2yv#+JaZ=AuS-;21qKO)By3wVkm!i1Ef4T1La=^$%E?uCk>Fe_|w3^
z&;x4uG%_%(V_;xd-3Te`qnaQ=pV0&fsj4PO>ThoX2O-0vCP*A_Y+_(=Vqjo6*90jS
z1e+mg%BvYtLZ&oBJXqPxz~B!Wl5J*Ss0R%Y-hnE3)(o-ecQYgecv~R#xJnBo&I4K?
zEv14MNHcmZRQzcRBvG=qLV{MY71EN@X@xl8BUGHb4U+oB+92wb+Mwfqx^1AiU|=w5
zgZQMN4U+hJ+aTHS5R^XC#=wxvz`$?~YLH7is13)!5YP?@nX-0Blr*<P(#GU=NXfXS
zodGo1%&-MYA8&`4d#k-35;RYs5<j5^@N_URtYTnbkm-O#!SN1=x~m-!gI{$(e8$`f
zDGy{jAtjwdC&WQvosf{M>4YTOzD`JP*xbp$FpGhKL7)p#cdV}OVqo|T8dUCv^zmeS
zAc<sK55(nrdmuq}v<KpVi#?EX;aLv@!%WcddoQHfzP1<Q5QjcUnn>=06wO6_3=EGz
zjnRHccV1!wB#m@TfQ${*pPm3|Mhi@YR1#Jb85n{X7#Qj%LJFLV6CoD9pUA-A4GNJ-
z44~c=L&hXXk-KRUBws(B1j+X=CqY8u-6RGE5k>|EmdTI;O>zn(J4Z}`sBfGCj)HoI
z)+rE+`lmo@t+i7i2Ar7!iPJ|@7#Ok{7#QA9ffy7$72<$dQz5zF$y5f0eg+1H|5G7}
zs(%_J=x0r1U<hPjU|2H^QsDfX1_?o->5%NMJ{=Uo^$ZLK(;-1@F&z>TF4G}JY2I{5
zs-6p#-#Hy((9!7-huxeGDFL5NhvX9W84w2;&w%8AyBUy{kpB!wqjmZW$Pg{pOvr#|
z$xH@@4WRu0b|$3Km^Ta3sQf#NfuV|lfx%@qq&C|>n}OjIXd+=Yq~~*Y4x|qkJeL7H
zdbVyZB)6E)gZMmW9;C%nHxJUY+BXl9tGMSwJg6}rGQklvpMjws)W=&eAL7#M^C3m6
z`~rvpz6&5x)4KqY*j6uqRL8ChAt6(?5YjT5v=CzOm4y(W{#yuXU=%Kb@Lw;26wPsq
zAr9HLm;pS&ad|OAJvjCAEMZ_s1I;-th4>(SDI{(ymqMau%2G&>FIWmmWNV?~o1pYQ
zsQ9U+pg3h<IKLDU$Je3!2TLJQ@?t5ZNdCDLl6?i2P5vdRuAr(>oLXF*nV%P*oL`z(
zqM4$Qm|T)smZ|_1-YhR=!^j5_3WlgG-W(}i!Lj+2ax%A&S7~vHLSBA3gHK{{i9%^{
zYRYCEeIMTN)Uy2SRE8ihrH}|SOra!SAtygwp)$X;s5mtzO(DOuL?J&<Au%t%BqOy*
zAtkjeGdWdH!7slgRiPv!u|%OHBePf`vsfV~GdHs&HD$8Dop=z`WeSOTDGD}8b}9L(
z#R_@(B?^gYX{pI2APX|{U^W(mj4w_t0-0N^XQQ8F2X|CX4qVaZGMhq1<07zYQy8G$
zR4B>JO-%tg5hhX$XDC3`ZvJ4K!Nr=LmtUT@In+aj*BlynIr-^mZUB2E1?)et!;w8)
zthYHkRE~+)MAyJr*T_V{(89{teDbvLwT#x2og)fa3=Q;*CvT6?z!q8A;K&NjEU7BZ
zOW%AaqKlCiRVXRy2<K+`l(oFF?x{tIDX9vnNvU}ZQKdPlB~>K~WvNBwsd*`>C7Vwd
zt`pD-&rC@zDk@D&&0~Nw6;d<vQWc;!=qQxtDx_p4XH1?`Bwe3WRhpZclaiXJr{I*D
zn4D3Ps*sXcT&$3inOdxnSemAgf~;2|Ggl!vGcP?SRW~>@Jx|v^Pa!F_I60%}@RGdj
z5`|1q0?JEKD9%jI0L5!wW^zUe*c7PUsi1_S;F6h|S6l*Omns*RXBK6bK%&(#CkO7z
z)I5c9s82SRHEm(!g7H%GCM&jxNWl$cfU}?)HXF1|<lzj>OG?d7%*!j;d|-knze%b>
sA~;O*V4(oEJq_&5)LezqycAHBrWU2<DY&N=9bQtBny0s!Wq|?{070s>zyJUM

delta 6840
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
L5B|*pOQe|q60w7q

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 4f4d621..59f10cd 100644
--- a/uffd/translations/de/LC_MESSAGES/messages.po
+++ b/uffd/translations/de/LC_MESSAGES/messages.po
@@ -7,30 +7,30 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2022-11-13 02:05+0100\n"
+"POT-Creation-Date: 2024-03-24 18:37+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
 "Language-Team: de <LL@li.org>\n"
-"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.8.0\n"
+"Generated-By: Babel 2.10.3\n"
 
-#: uffd/models/invite.py:82 uffd/models/invite.py:105 uffd/models/invite.py:110
+#: uffd/models/invite.py:84 uffd/models/invite.py:107 uffd/models/invite.py:112
 msgid "Invite link is invalid"
 msgstr "Einladungslink ist ungültig"
 
-#: uffd/models/invite.py:84
+#: uffd/models/invite.py:86
 msgid "Invite link does not grant any roles"
 msgstr "Einladungslink weist keine Rollen zu"
 
-#: uffd/models/invite.py:86
+#: uffd/models/invite.py:88
 msgid "Invite link does not grant any new roles"
 msgstr "Einladungslink weist keine neuen Rollen zu"
 
-#: uffd/models/invite.py:91 uffd/models/signup.py:122
+#: uffd/models/invite.py:93 uffd/models/signup.py:122
 #: uffd/templates/mfa/setup.html:225
 msgid "Success"
 msgstr "Erfolgreich"
@@ -61,6 +61,11 @@ msgstr "eine Stunde"
 msgid "%(hours)d hours"
 msgstr "%(hours)d Stunden"
 
+#: uffd/models/session.py:62 uffd/models/session.py:74
+#: uffd/models/session.py:80 uffd/models/session.py:90
+msgid "Unknown"
+msgstr "Unbekannt"
+
 #: uffd/models/signup.py:78 uffd/models/signup.py:103
 msgid "Invalid signup request"
 msgstr "Ungültiger Account-Registrierungs-Link"
@@ -117,28 +122,28 @@ msgstr "Zugriff verweigert"
 msgid "You don't have the permission to access this page."
 msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
 
-#: uffd/templates/base.html:84
+#: uffd/templates/base.html:85
 msgid "Change"
 msgstr "Ändern"
 
-#: uffd/templates/base.html:92 uffd/templates/session/deviceauth.html:12
+#: uffd/templates/base.html:93 uffd/templates/session/deviceauth.html:12
 msgid "Authorize Device Login"
 msgstr "Gerätelogin erlauben"
 
-#: uffd/templates/base.html:93 uffd/templates/session/devicelogin.html:6
+#: uffd/templates/base.html:94 uffd/templates/session/devicelogin.html:6
 msgid "Device Login"
 msgstr "Gerätelogin"
 
-#: uffd/templates/base.html:99 uffd/templates/oauth2/logout.html:5
+#: uffd/templates/base.html:100 uffd/templates/oauth2/logout.html:5
 msgid "Logout"
 msgstr "Abmelden"
 
-#: uffd/templates/base.html:106 uffd/templates/service/overview.html:15
+#: uffd/templates/base.html:107 uffd/templates/service/overview.html:15
 #: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20
 msgid "Login"
 msgstr "Anmelden"
 
-#: uffd/templates/base.html:142
+#: uffd/templates/base.html:143
 msgid "About uffd"
 msgstr "Über uffd"
 
@@ -160,18 +165,18 @@ msgstr "GID"
 #: uffd/templates/mfa/setup.html:157 uffd/templates/mfa/setup.html:158
 #: uffd/templates/mfa/setup.html:169 uffd/templates/role/list.html:14
 #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44
-#: uffd/templates/selfservice/self.html:190
+#: uffd/templates/selfservice/self.html:239
 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
-#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:205
-#: uffd/templates/user/show.html:237
+#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:212
+#: uffd/templates/user/show.html:244
 msgid "Name"
 msgstr "Name"
 
 #: uffd/templates/group/list.html:16 uffd/templates/group/show.html:33
 #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15
 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10
-#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:191
-#: uffd/templates/user/show.html:206 uffd/templates/user/show.html:238
+#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:240
+#: uffd/templates/user/show.html:213 uffd/templates/user/show.html:245
 msgid "Description"
 msgstr "Beschreibung"
 
@@ -195,9 +200,11 @@ msgstr "Abbrechen"
 
 #: uffd/templates/group/show.html:11 uffd/templates/role/show.html:19
 #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61
-#: uffd/templates/selfservice/self.html:205 uffd/templates/service/api.html:11
+#: uffd/templates/selfservice/self.html:210
+#: uffd/templates/selfservice/self.html:254 uffd/templates/service/api.html:11
 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12
 #: uffd/templates/user/show.html:41 uffd/templates/user/show.html:193
+#: uffd/templates/user/show.html:199
 msgid "Are you sure?"
 msgstr "Wirklich fortfahren?"
 
@@ -1154,13 +1161,48 @@ msgstr ""
 msgid "Manage two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
 
-#: uffd/templates/selfservice/self.html:178 uffd/templates/user/list.html:20
-#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:200
+#: uffd/templates/selfservice/self.html:178
+msgid "Active Sessions"
+msgstr "Aktive Sitzungen"
+
+#: uffd/templates/selfservice/self.html:179
+msgid "Your active login sessions on this device and other devices."
+msgstr "Deine aktiven Sitzungen auf diesem und anderen Geräten."
+
+#: uffd/templates/selfservice/self.html:180
+msgid ""
+"Revoke a session to log yourself out on another device. Note that this is"
+" limited to the Single-Sign-On session and <b>does not affect login "
+"sessions on services.</b>"
+msgstr ""
+"Widerrufe eine Sitzung, um dich auf einem anderen Gerät abzumelden. "
+"Beachte dass dies auf deine Sitzung im Single-Sign-On beschränkt ist und "
+"sich <b>nicht auf Sitzungen an Diensten auswirkt.</b>"
+
+#: uffd/templates/selfservice/self.html:186
+msgid "Last used"
+msgstr "Zuletzt verwendet"
+
+#: uffd/templates/selfservice/self.html:187
+msgid "Device"
+msgstr "Gerät"
+
+#: uffd/templates/selfservice/self.html:193
+#: uffd/templates/selfservice/self.html:202
+msgid "Just now"
+msgstr "Gerade eben"
+
+#: uffd/templates/selfservice/self.html:211
+msgid "Revoke"
+msgstr "Widerrufen"
+
+#: uffd/templates/selfservice/self.html:227 uffd/templates/user/list.html:20
+#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:207
 #: uffd/views/role.py:21
 msgid "Roles"
 msgstr "Rollen"
 
-#: uffd/templates/selfservice/self.html:179
+#: uffd/templates/selfservice/self.html:228
 msgid ""
 "Aside from a set of base permissions, your roles determine the "
 "permissions of your account."
@@ -1168,7 +1210,7 @@ msgstr ""
 "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, "
 "von deinen Rollen bestimmt"
 
-#: uffd/templates/selfservice/self.html:181
+#: uffd/templates/selfservice/self.html:230
 #, python-format
 msgid ""
 "See <a href=\"%(services_url)s\">Services</a> for an overview of your "
@@ -1177,13 +1219,13 @@ msgstr ""
 "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick "
 "über deine aktuellen Berechtigungen."
 
-#: uffd/templates/selfservice/self.html:185
+#: uffd/templates/selfservice/self.html:234
 msgid "Administrators and role moderators can invite you to new roles."
 msgstr ""
 "Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu "
 "Rollen einladen."
 
-#: uffd/templates/selfservice/self.html:200
+#: uffd/templates/selfservice/self.html:249
 msgid ""
 "Some permissions in this role require you to setup two-factor "
 "authentication"
@@ -1191,11 +1233,11 @@ msgstr ""
 "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
 "Faktor-Authentifikation"
 
-#: uffd/templates/selfservice/self.html:206
+#: uffd/templates/selfservice/self.html:255
 msgid "Leave"
 msgstr "Verlassen"
 
-#: uffd/templates/selfservice/self.html:213
+#: uffd/templates/selfservice/self.html:262
 msgid "You currently don't have any roles"
 msgstr "Du hast derzeit keine Rollen"
 
@@ -1670,7 +1712,20 @@ msgstr "Aktiv"
 msgid "Reset 2FA"
 msgstr "2FA zurücksetzen"
 
-#: uffd/templates/user/show.html:233
+#: uffd/templates/user/show.html:197
+msgid "Sessions"
+msgstr "Sitzungen"
+
+#: uffd/templates/user/show.html:198
+#, python-format
+msgid "%(session_count)d active sessions"
+msgstr "%(session_count)d aktive Sitzungen"
+
+#: uffd/templates/user/show.html:199
+msgid "Revoke all sessions"
+msgstr "Alle Sitzungen widerrufen"
+
+#: uffd/templates/user/show.html:240
 msgid "Resulting groups (only updated after save)"
 msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)"
 
@@ -1791,16 +1846,16 @@ msgstr ""
 "2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
 "werden konnte (%s)"
 
-#: uffd/views/mfa.py:214
+#: uffd/views/mfa.py:216
 #, python-format
 msgid "We received too many invalid attempts! Please wait at least %s."
 msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
 
-#: uffd/views/mfa.py:228
+#: uffd/views/mfa.py:231
 msgid "You have exhausted your recovery codes. Please generate new ones now!"
 msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
 
-#: uffd/views/mfa.py:231
+#: uffd/views/mfa.py:234
 msgid ""
 "You only have a few recovery codes remaining. Make sure to generate new "
 "ones before they run out."
@@ -1808,12 +1863,12 @@ msgstr ""
 "Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
 "diese erneut bevor keine mehr übrig sind."
 
-#: uffd/views/mfa.py:235
+#: uffd/views/mfa.py:238
 msgid "Two-factor authentication failed"
 msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
 
-#: uffd/views/oauth2.py:172 uffd/views/selfservice.py:66
-#: uffd/views/session.py:67
+#: uffd/views/oauth2.py:267 uffd/views/selfservice.py:66
+#: uffd/views/session.py:86
 #, python-format
 msgid ""
 "We received too many requests from your ip address/network! Please wait "
@@ -1822,19 +1877,19 @@ msgstr ""
 "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
 "Netzwerk empfangen! Bitte warte mindestens %(delay)s."
 
-#: uffd/views/oauth2.py:180
+#: uffd/views/oauth2.py:278
 msgid "Device login is currently not available. Try again later!"
 msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
 
-#: uffd/views/oauth2.py:193
+#: uffd/views/oauth2.py:296
 msgid "Device login failed"
 msgstr "Gerätelogin fehlgeschlagen"
 
-#: uffd/views/oauth2.py:199
+#: uffd/views/oauth2.py:304
 msgid "You need to login to access this service"
 msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
 
-#: uffd/views/oauth2.py:206
+#: uffd/views/oauth2.py:335
 #, python-format
 msgid ""
 "You don't have the permission to access the service "
@@ -1918,7 +1973,7 @@ msgid "E-Mail address already exists"
 msgstr "E-Mail-Adresse existiert bereits"
 
 #: uffd/views/selfservice.py:124 uffd/views/selfservice.py:162
-#: uffd/views/selfservice.py:227
+#: uffd/views/selfservice.py:237
 #, python-format
 msgid "E-Mail to \"%(mail_address)s\" could not be sent!"
 msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
@@ -1951,7 +2006,11 @@ msgstr "E-Mail-Adresse gelöscht"
 msgid "E-Mail preferences updated"
 msgstr "E-Mail-Einstellungen geändert"
 
-#: uffd/views/selfservice.py:209
+#: uffd/views/selfservice.py:208
+msgid "Session revoked"
+msgstr "Sitzung widerrufen"
+
+#: uffd/views/selfservice.py:219
 #, python-format
 msgid "You left role %(role_name)s"
 msgstr "Rolle %(role_name)s verlassen"
@@ -1960,7 +2019,7 @@ msgstr "Rolle %(role_name)s verlassen"
 msgid "Services"
 msgstr "Dienste"
 
-#: uffd/views/session.py:65
+#: uffd/views/session.py:84
 #, python-format
 msgid ""
 "We received too many invalid login attempts for this user! Please wait at"
@@ -1969,34 +2028,34 @@ msgstr ""
 "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
 "erhalten! Bitte warte mindestens %(delay)s."
 
-#: uffd/views/session.py:74
+#: uffd/views/session.py:93
 msgid "Login name or password is wrong"
 msgstr "Der Anmeldename oder das Passwort ist falsch"
 
-#: uffd/views/session.py:77
+#: uffd/views/session.py:96
 #, python-format
 msgid "Your account is deactivated. Contact %(contact_email)s for details."
 msgstr ""
 "Dein Account ist deaktiviert. Kontaktiere %(contact_email)s für weitere "
 "Informationen."
 
-#: uffd/views/session.py:83
+#: uffd/views/session.py:102
 msgid "You do not have access to this service"
 msgstr "Du hast keinen Zugriff auf diesen Service"
 
-#: uffd/views/session.py:95 uffd/views/session.py:106
+#: uffd/views/session.py:114 uffd/views/session.py:125
 msgid "You need to login first"
 msgstr "Du musst dich erst anmelden"
 
-#: uffd/views/session.py:127 uffd/views/session.py:137
+#: uffd/views/session.py:146 uffd/views/session.py:156
 msgid "Initiation code is no longer valid"
 msgstr "Startcode ist nicht mehr gültig"
 
-#: uffd/views/session.py:141
+#: uffd/views/session.py:160
 msgid "Invalid confirmation code"
 msgstr "Ungültiger Bestätigungscode"
 
-#: uffd/views/session.py:153 uffd/views/session.py:164
+#: uffd/views/session.py:172 uffd/views/session.py:183
 msgid "Invalid initiation code"
 msgstr "Ungültiger Startcode"
 
@@ -2061,7 +2120,11 @@ msgstr "Account deaktiviert"
 msgid "User activated"
 msgstr "Account aktiviert"
 
-#: uffd/views/user.py:174
+#: uffd/views/user.py:173
+msgid "Sessions revoked"
+msgstr "Sitzungen widerrufen"
+
+#: uffd/views/user.py:183
 msgid "Deleted user"
 msgstr "Account gelöscht"
 
diff --git a/uffd/views/mfa.py b/uffd/views/mfa.py
index 3aab437..d8f0d99 100644
--- a/uffd/views/mfa.py
+++ b/uffd/views/mfa.py
@@ -182,7 +182,8 @@ if WEBAUTHN_SUPPORTED:
 			auth_data,
 			signature,
 		)
-		session['user_mfa'] = True
+		request.session.mfa_done = True
+		db.session.commit()
 		set_request_user()
 		return cbor.encode({"status": "OK"})
 
@@ -200,9 +201,10 @@ def delete_webauthn(id): #pylint: disable=redefined-builtin
 @login_required_pre_mfa()
 def auth():
 	if not request.user_pre_mfa.mfa_enabled:
-		session['user_mfa'] = True
+		request.session.mfa_done = True
+		db.session.commit()
 		set_request_user()
-	if session.get('user_mfa'):
+	if request.session.mfa_done:
 		return secure_local_redirect(request.values.get('ref', url_for('index')))
 	return render_template('mfa/auth.html', ref=request.values.get('ref'))
 
@@ -215,15 +217,15 @@ def auth_finish():
 		return redirect(url_for('mfa.auth', ref=request.values.get('ref')))
 	for method in request.user_pre_mfa.mfa_totp_methods:
 		if method.verify(request.form['code']):
+			request.session.mfa_done = True
 			db.session.commit()
-			session['user_mfa'] = True
 			set_request_user()
 			return secure_local_redirect(request.values.get('ref', url_for('index')))
 	for method in request.user_pre_mfa.mfa_recovery_codes:
 		if method.verify(request.form['code']):
 			db.session.delete(method)
+			request.session.mfa_done = True
 			db.session.commit()
-			session['user_mfa'] = True
 			set_request_user()
 			if len(request.user_pre_mfa.mfa_recovery_codes) <= 1:
 				flash(_('You have exhausted your recovery codes. Please generate new ones now!'))
diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py
index bbf062b..3d8c821 100644
--- a/uffd/views/selfservice.py
+++ b/uffd/views/selfservice.py
@@ -8,7 +8,7 @@ from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.sendmail import sendmail
 from uffd.database import db
-from uffd.models import User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay
+from uffd.models import User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay, Session
 from .session import login_required
 
 bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/')
@@ -198,6 +198,16 @@ def update_email_preferences():
 	flash(_('E-Mail preferences updated'))
 	return redirect(url_for('selfservice.index'))
 
+@bp.route("/session/<int:session_id>/revoke", methods=(['POST']))
+@csrf_protect(blueprint=bp)
+@login_required(selfservice_acl_check)
+def revoke_session(session_id):
+	session = Session.query.filter_by(id=session_id, user=request.user).first_or_404()
+	db.session.delete(session)
+	db.session.commit()
+	flash(_('Session revoked'))
+	return redirect(url_for('selfservice.index'))
+
 @bp.route("/leaverole/<int:roleid>", methods=(['POST']))
 @csrf_protect(blueprint=bp)
 @login_required(selfservice_acl_check)
diff --git a/uffd/views/session.py b/uffd/views/session.py
index df3301a..548b80b 100644
--- a/uffd/views/session.py
+++ b/uffd/views/session.py
@@ -8,7 +8,7 @@ from flask_babel import gettext as _
 from uffd.database import db
 from uffd.csrf import csrf_protect
 from uffd.secure_redirect import secure_local_redirect
-from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay
+from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay, Session
 
 bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/')
 
@@ -18,35 +18,54 @@ login_ratelimit = Ratelimit('login', 1*60, 3)
 def set_request_user():
 	request.user = None
 	request.user_pre_mfa = None
-	if 'user_id' not in session:
+	request.session = None
+	if 'id' not in session:
 		return
-	if 'logintime' not in session:
+	if 'secret' not in session:
 		return
-	if datetime.datetime.utcnow().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']:
+	_session = Session.query.get(session['id'])
+	if _session is None or not _session.secret.verify(session['secret']) or _session.expired:
 		return
-	user = User.query.get(session['user_id'])
-	if not user or user.is_deactivated or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
+	if _session.last_used <= datetime.datetime.utcnow() - datetime.timedelta(seconds=60):
+		_session.last_used = datetime.datetime.utcnow()
+		_session.ip_address = request.remote_addr
+		_session.user_agent = request.user_agent.string
+		db.session.commit()
+	if _session.user.is_deactivated or not _session.user.is_in_group(current_app.config['ACL_ACCESS_GROUP']):
 		return
-	request.user_pre_mfa = user
-	if session.get('user_mfa'):
-		request.user = user
+	request.session = _session
+	request.user_pre_mfa = _session.user
+	if _session.mfa_done:
+		request.user = _session.user
 
 @bp.route("/logout")
 def logout():
 	# The oauth2 module takes data from `session` and injects it into the url,
 	# so we need to build the url BEFORE we clear the session!
 	resp = redirect(url_for('oauth2.logout', ref=request.values.get('ref', url_for('.login'))))
+	if request.session:
+		db.session.delete(request.session)
+		db.session.commit()
 	session.clear()
 	return resp
 
 def set_session(user, skip_mfa=False):
 	session.clear()
 	session.permanent = True
-	session['user_id'] = user.id
-	session['logintime'] = datetime.datetime.utcnow().timestamp()
-	session['_csrf_token'] = secrets.token_hex(128)
+	secret = secrets.token_hex(128)
+	_session = Session(
+		user=user,
+		secret=secret,
+		ip_address=request.remote_addr,
+		user_agent=request.user_agent.string,
+	)
 	if skip_mfa:
-		session['user_mfa'] = True
+		_session.mfa_done = True
+	db.session.add(_session)
+	db.session.commit()
+	session['id'] = _session.id
+	session['secret'] = secret
+	session['_csrf_token'] = secrets.token_hex(128)
 
 @bp.route("/login", methods=('GET', 'POST'))
 def login():
diff --git a/uffd/views/user.py b/uffd/views/user.py
index 51d4d64..d7fb4ef 100644
--- a/uffd/views/user.py
+++ b/uffd/views/user.py
@@ -164,6 +164,15 @@ def activate(id):
 	flash(_('User activated'))
 	return redirect(url_for('user.show', id=user.id))
 
+@bp.route('/<int:id>/sessions/revoke')
+@csrf_protect(blueprint=bp)
+def revoke_sessions(id):
+	user = User.query.get_or_404(id)
+	user.sessions.clear()
+	db.session.commit()
+	flash(_('Sessions revoked'))
+	return redirect(url_for('user.show', id=user.id))
+
 @bp.route("/<int:id>/del")
 @csrf_protect(blueprint=bp)
 def delete(id):
-- 
GitLab