diff --git a/README.md b/README.md index 2c51d402193a9a58c7dcf7f5f9c51b156088bf4a..984acc8eb473b8b88ebdb5361887a3b46a371f2c 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 a9a87dd55c0dc858213f318844900b24cbe18172..e206c489378e9765293976b31038f9edd24f6db3 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 189b301027b6a4d242ad3e766b07d7160f5cfd0f..31db92ff18e3250be26528d79407827e0fbab785 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 0000000000000000000000000000000000000000..291c63a09a74011b07e39cc9577898adad35b67d --- /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 0000000000000000000000000000000000000000..e8fea9021337958a8ce4723e2e1f1e8b85ff0efc --- /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 756a97212bc4b7d819700ddd1f313a481b81a1e3..2dcf9a713e92577d27f6aa82dadeba3cac364f22 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 8e5f847fe22ae42879476ab6e312bfe6c87d7581..842d4ec281b085a61031182e3be8c68c843b8abb 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 48bd4cd9ebcf2402a1e2585448790337880afe04..70cba2588dd54d666a704675a7d72c18531e53a8 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 548791df49035309f30c8d332482f04aaf72480c..cc977f8f03c9436509728919d5a24b6203f729d4 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 ed4b2bd961794aaff339863dc10ec6bfdaa6b902..04a0d6835b8d0c29aac4973df7edda81d4652d36 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 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 4f4d621c0e7c7c28629c69dc0a1e8584c4bb0278..59f10cdcb2d209cc918b9c4da48713dd18c4d33c 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 3aab43749ed6c8489a9c66a3f73ecc014ea7920e..d8f0d99e559e4361a72cd844d88db6eda53c0876 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 bbf062bfaf3bce1529d1433102a6041d573cb6b3..3d8c821da580af2c0351d638d0a4ebad247f1df9 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 df3301af7b2982069635f5e3646eb550d44c1c86..548b80be64c69a4392578bc8513f8093cea974fa 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 51d4d64e7f734f2e8f0c4acebcbc800dea50c2f4..d7fb4ef749e47a6c1aae4d3f3d28ac93d1843859 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):