diff options
author | Daniel Schadt <kingdread@gmx.de> | 2023-10-15 00:06:09 +0200 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2023-10-15 00:06:09 +0200 |
commit | fcb0f5cd199e68fff502943e048db2d0ac93503a (patch) | |
tree | 82e27ca1a86c0b59c0201753732562f3037efcc8 | |
parent | ab37c2dcbcc6916d38ecc2c3e59d9c94711e52fb (diff) | |
parent | bae6499475d0f9f735caebeda56755183ced5aeb (diff) | |
download | fietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.tar.gz fietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.tar.bz2 fietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.zip |
Merge branch 'session-secrets'
-rw-r--r-- | doc/developer.rst | 1 | ||||
-rw-r--r-- | doc/developer/authentication.rst | 115 | ||||
-rw-r--r-- | fietsboek/alembic/versions/20230914_4566843039d6.py | 37 | ||||
-rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.mo | bin | 14714 -> 15259 bytes | |||
-rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.po | 41 | ||||
-rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.mo | bin | 13738 -> 14269 bytes | |||
-rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.po | 41 | ||||
-rw-r--r-- | fietsboek/locale/fietslog.pot | 38 | ||||
-rw-r--r-- | fietsboek/models/user.py | 38 | ||||
-rw-r--r-- | fietsboek/routes.py | 1 | ||||
-rw-r--r-- | fietsboek/scripts/fietsctl.py | 1 | ||||
-rw-r--r-- | fietsboek/templates/user_data.jinja2 | 11 | ||||
-rw-r--r-- | fietsboek/views/account.py | 1 | ||||
-rw-r--r-- | fietsboek/views/user_data.py | 26 | ||||
-rw-r--r-- | tests/playwright/conftest.py | 2 |
15 files changed, 304 insertions, 49 deletions
diff --git a/doc/developer.rst b/doc/developer.rst index 8b41c60..02876dc 100644 --- a/doc/developer.rst +++ b/doc/developer.rst @@ -8,6 +8,7 @@ Developer Guide developer/localize developer/language-pack developer/js-css + developer/authentication Python Packages <developer/module/modules> This guide contains information for developers that want to modify or extend diff --git a/doc/developer/authentication.rst b/doc/developer/authentication.rst new file mode 100644 index 0000000..c52abeb --- /dev/null +++ b/doc/developer/authentication.rst @@ -0,0 +1,115 @@ +Authentication & Sessions +========================= + +This document gives a quick overview over the way that Fietsboek uses sessions +and authenticates users, going a bit into the technical details and reasoning +behind them. Fietsboek mostly uses `Pyramid's authentication facilities`_, +using hashed passwords in the database, and combining both the +``SessionAuthenticationHelper`` and the ``AuthTktCookieHelper``. + +.. _Pyramid's authentication facilities: https://docs.pylonsproject.org/projects/pyramid/en/latest/api/authentication.html + +Password Storage +---------------- + +Fietsboek stores passwords as a salted `scrypt`_ hash, using the implementation +in the `cryptography`_ library. The parameters Fietsboek uses are taken from +the ``cryptography`` documentation, which in turn takes them from `RFC 7914`_: + +.. code:: python + + SCRYPT_PARAMETERS = { + "length": 32, + "n": 2**14, + "r": 8, + "p": 1, + } + +We note that the recommendation is from 2016, so a slightly increased value of +``n`` could be chosen to stay around the 100 ms target. The `Go documentation`_ +mentions ``n = 2**15``. The reason why this is not updated yet is that it would +require logic to update password hashes when users log in, which has not been +implemented. + +The randomness for the salt is taken from Python's built-in `secrets`_ module, +using high-quality random sources. + +.. _scrypt: https://en.wikipedia.org/wiki/Scrypt +.. _cryptography: https://cryptography.io/en/latest/ +.. _RFC 7914: https://datatracker.ietf.org/doc/html/rfc7914.html +.. _Go documentation: https://pkg.go.dev/golang.org/x/crypto/scrypt#Key +.. _secrets: https://docs.python.org/3/library/secrets.html + +Cookies +------- + +The authentication logic uses two cookies: one session cookie for normal +log-ins, and one long-lived cookie for the "Remember me" functionality. The +reason for the cookie split is that session cookies expire after a few minutes, +and by keeping a separate long-lived cookie, we can realize a "Remember me" +functionality without destroying the timeout for the remainder of the session +data. + +The flow in the login handler therefore does two steps: + +#. If the session contains a valid user data, the user is considered + authenticated. +#. If not, the "Remember me"-cookie is checked. If it contains a valid user + data, the session fingerprint is set to match, and the user is + considered authenticated. + +The user data in the cookie consists of two parts: the user ID in plain, which +is used to quickly retrieve the right user from the database, and the user +fingerprint, which is used to verify that the user is the correct one (see +below). + +It is important to note that the session and the cookie are signed with the +application secret. Therefore, it is not possible to easily forge a valid +authentication cookie. + +User Fingerprint +---------------- + +The cookie signature makes it hard to forge valid authentication tokens in the +first place. However, even with the signature mechanism in place, there are (at +least) two possible issues if we leave away the extra fingerprint: + +Firstly, while no "new" authentication cookies can be forged, a legitimate +cookie that is saved might lead to a wrong user being retrieved. For example, +if someone saves their authentication cookie, then deletes their user account, +and the database re-uses the ID for the next new user, then the old +authentication cookie could be used to gain access to the new account. + +Secondly, as sessions and cookies are client-side, we cannot influence them +from the server. Therefore, there would be no way to invalidate a specific +cookie, or all authentication cookies for a specific user (short of changing +the application secret and invalidating *all* cookies). To provide a "Log out +all sessions" functionality, we need a way to invalidate sessions remotely. + +To solve those problems, we add a cryptographic hash (more precisely, a 10 byte +SHAKE_ hash) to the authentication cookie, which is formed over the following +data points: + +* The user's email address +* The hash of the user's password +* A random session secret, specific to each user + +This means that if any of those data points changes, the fingerprint will +change, and old authentication sessions and cookies will no longer be valid: + +* If the user changes their email address, we consider this a relevant change, + and require the user to login again. +* If a user changes their password, all sessions are also invalidated, and the + user needs to login again. +* If we want to invalidate the sessions without setting a new password (e.g. + because the user requests it), we simply set a new random session secret for + the user. + +We note that the fingerprint only offers 80 bits of security (which is rather +low), but the fingerprint is not the main line of defense against forged +logins: the main source of security for forged logins is the cookie signature. +The fingerprint only ensures that no "user mixups" can occur. So not only would +you need to brute-force the correct 80 bits of fingerprint, but for each +fingerprint you also need to generate a valid signature. + +.. _SHAKE: https://en.wikipedia.org/wiki/SHA-3 diff --git a/fietsboek/alembic/versions/20230914_4566843039d6.py b/fietsboek/alembic/versions/20230914_4566843039d6.py new file mode 100644 index 0000000..28dcbda --- /dev/null +++ b/fietsboek/alembic/versions/20230914_4566843039d6.py @@ -0,0 +1,37 @@ +"""Add session_secret column. + +Revision ID: 4566843039d6 +Revises: 8f4e4eae5eb2 +Create Date: 2023-09-14 19:33:51.646943 + +""" +import secrets + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '4566843039d6' +down_revision = '8f4e4eae5eb2' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('session_secret', sa.LargeBinary(length=32), nullable=True)) + bind = op.get_bind() + user_ids = bind.execute(sa.text("SELECT id FROM users;")).scalars() + for user_id in user_ids: + bind.execute( + sa.text("UPDATE users SET session_secret=:secret WHERE id=:user_id;"), + { + "secret": secrets.token_bytes(32), + "user_id": user_id, + }, + ) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'session_secret') + # ### end Alembic commands ### diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex e6dd1c6..f2bf3ea 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index b34e849..69723be 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.po +++ b/fietsboek/locale/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-08-17 22:51+0200\n" +"POT-Creation-Date: 2023-09-14 20:05+0200\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -753,22 +753,37 @@ msgid "page.my_profile.personal_data.save" msgstr "Speichern" #: fietsboek/templates/user_data.jinja2:38 +msgid "page.my_profile.session_logout.title" +msgstr "Sitzungen abmelden" + +#: fietsboek/templates/user_data.jinja2:40 +msgid "page.my_profile.session_logout.explanation" +msgstr "" +"Mit dieser Funktion können alle Sitzungen beendet werden. Dies ist nützlich, " +"wenn Du vergessen hast, dich auf einem fremden Gerät abzumelden. Beachte, dass " +"Du dich erneut anmelden musst, wenn Du diese Funktion nutzt!" + +#: fietsboek/templates/user_data.jinja2:44 +msgid "page.my_profile.session_logout.button" +msgstr "Alle Sitzungen beenden" + +#: fietsboek/templates/user_data.jinja2:49 msgid "page.my_profile.friends" msgstr "Freunde" -#: fietsboek/templates/user_data.jinja2:46 +#: fietsboek/templates/user_data.jinja2:57 msgid "page.my_profile.unfriend" msgstr "Entfreunden" -#: fietsboek/templates/user_data.jinja2:56 +#: fietsboek/templates/user_data.jinja2:67 msgid "page.my_profile.accept_friend" msgstr "Annehmen" -#: fietsboek/templates/user_data.jinja2:73 +#: fietsboek/templates/user_data.jinja2:84 msgid "page.my_profile.friend_request_email" msgstr "E-Mail-Adresse des Freundes" -#: fietsboek/templates/user_data.jinja2:77 +#: fietsboek/templates/user_data.jinja2:88 msgid "page.my_profile.send_friend_request" msgstr "Freundschaftsanfrage senden" @@ -808,7 +823,7 @@ msgstr "Ungültiger Name" msgid "flash.invalid_email" msgstr "Ungültige E-Mail-Adresse" -#: fietsboek/views/account.py:67 +#: fietsboek/views/account.py:68 msgid "flash.a_confirmation_link_has_been_sent" msgstr "Ein Bestätigungslink wurde versandt" @@ -900,23 +915,27 @@ msgstr "Hochladen erfolgreich" msgid "flash.upload_cancelled" msgstr "Hochladen abgebrochen" -#: fietsboek/views/user_data.py:61 +#: fietsboek/views/user_data.py:66 msgid "flash.personal_data_updated" msgstr "Persönliche Daten wurden gespeichert" -#: fietsboek/views/user_data.py:79 +#: fietsboek/views/user_data.py:85 msgid "flash.friend_not_found" msgstr "Das angegebene Konto wurde nicht gefunden" -#: fietsboek/views/user_data.py:85 +#: fietsboek/views/user_data.py:91 msgid "flash.friend_already_exists" msgstr "Dieser Freund existiert bereits" -#: fietsboek/views/user_data.py:93 +#: fietsboek/views/user_data.py:99 msgid "flash.friend_added" msgstr "Freund hinzugefügt" -#: fietsboek/views/user_data.py:103 +#: fietsboek/views/user_data.py:109 msgid "flash.friend_request_sent" msgstr "Freundschaftsanfrage gesendet" +#: fietsboek/views/user_data.py:195 +msgid "flash.sessions_logged_out" +msgstr "Die Sitzungen wurden beendet. Melde Dich bitte erneut an, um fortzufahren." + diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo Binary files differindex 56137c1..da23e31 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index c1ff29e..45b15cc 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-08-17 22:51+0200\n" +"POT-Creation-Date: 2023-09-14 20:05+0200\n" "PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" "Language: en\n" @@ -747,22 +747,37 @@ msgid "page.my_profile.personal_data.save" msgstr "Save" #: fietsboek/templates/user_data.jinja2:38 +msgid "page.my_profile.session_logout.title" +msgstr "Invalidate sessions" + +#: fietsboek/templates/user_data.jinja2:40 +msgid "page.my_profile.session_logout.explanation" +msgstr "" +"With this functionality, you can force all of your current sessions " +"to be logged out. This is useful when you forgot to log out on a foreign " +"device. Note that you will have to log in again after using this function." + +#: fietsboek/templates/user_data.jinja2:44 +msgid "page.my_profile.session_logout.button" +msgstr "Close all sessions" + +#: fietsboek/templates/user_data.jinja2:49 msgid "page.my_profile.friends" msgstr "Friends" -#: fietsboek/templates/user_data.jinja2:46 +#: fietsboek/templates/user_data.jinja2:57 msgid "page.my_profile.unfriend" msgstr "Unfriend" -#: fietsboek/templates/user_data.jinja2:56 +#: fietsboek/templates/user_data.jinja2:67 msgid "page.my_profile.accept_friend" msgstr "Accept" -#: fietsboek/templates/user_data.jinja2:73 +#: fietsboek/templates/user_data.jinja2:84 msgid "page.my_profile.friend_request_email" msgstr "Email of the friend" -#: fietsboek/templates/user_data.jinja2:77 +#: fietsboek/templates/user_data.jinja2:88 msgid "page.my_profile.send_friend_request" msgstr "Send friend request" @@ -798,7 +813,7 @@ msgstr "Invalid name" msgid "flash.invalid_email" msgstr "Invalid email" -#: fietsboek/views/account.py:67 +#: fietsboek/views/account.py:68 msgid "flash.a_confirmation_link_has_been_sent" msgstr "A confirmation link has been sent" @@ -889,23 +904,27 @@ msgstr "Upload successful" msgid "flash.upload_cancelled" msgstr "Upload cancelled" -#: fietsboek/views/user_data.py:61 +#: fietsboek/views/user_data.py:66 msgid "flash.personal_data_updated" msgstr "Personal data has been updated" -#: fietsboek/views/user_data.py:79 +#: fietsboek/views/user_data.py:85 msgid "flash.friend_not_found" msgstr "The friend was not found" -#: fietsboek/views/user_data.py:85 +#: fietsboek/views/user_data.py:91 msgid "flash.friend_already_exists" msgstr "Friend already exists" -#: fietsboek/views/user_data.py:93 +#: fietsboek/views/user_data.py:99 msgid "flash.friend_added" msgstr "Friend has been added" -#: fietsboek/views/user_data.py:103 +#: fietsboek/views/user_data.py:109 msgid "flash.friend_request_sent" msgstr "Friend request sent" +#: fietsboek/views/user_data.py:195 +msgid "flash.sessions_logged_out" +msgstr "All sessions have been logged out. Please log in again to continue." + diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index ed93194..b87785e 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-08-17 22:51+0200\n" +"POT-Creation-Date: 2023-09-14 20:05+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -739,22 +739,34 @@ msgid "page.my_profile.personal_data.save" msgstr "" #: fietsboek/templates/user_data.jinja2:38 +msgid "page.my_profile.session_logout.title" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:40 +msgid "page.my_profile.session_logout.explanation" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:44 +msgid "page.my_profile.session_logout.button" +msgstr "" + +#: fietsboek/templates/user_data.jinja2:49 msgid "page.my_profile.friends" msgstr "" -#: fietsboek/templates/user_data.jinja2:46 +#: fietsboek/templates/user_data.jinja2:57 msgid "page.my_profile.unfriend" msgstr "" -#: fietsboek/templates/user_data.jinja2:56 +#: fietsboek/templates/user_data.jinja2:67 msgid "page.my_profile.accept_friend" msgstr "" -#: fietsboek/templates/user_data.jinja2:73 +#: fietsboek/templates/user_data.jinja2:84 msgid "page.my_profile.friend_request_email" msgstr "" -#: fietsboek/templates/user_data.jinja2:77 +#: fietsboek/templates/user_data.jinja2:88 msgid "page.my_profile.send_friend_request" msgstr "" @@ -790,7 +802,7 @@ msgstr "" msgid "flash.invalid_email" msgstr "" -#: fietsboek/views/account.py:67 +#: fietsboek/views/account.py:68 msgid "flash.a_confirmation_link_has_been_sent" msgstr "" @@ -878,23 +890,27 @@ msgstr "" msgid "flash.upload_cancelled" msgstr "" -#: fietsboek/views/user_data.py:61 +#: fietsboek/views/user_data.py:66 msgid "flash.personal_data_updated" msgstr "" -#: fietsboek/views/user_data.py:79 +#: fietsboek/views/user_data.py:85 msgid "flash.friend_not_found" msgstr "" -#: fietsboek/views/user_data.py:85 +#: fietsboek/views/user_data.py:91 msgid "flash.friend_already_exists" msgstr "" -#: fietsboek/views/user_data.py:93 +#: fietsboek/views/user_data.py:99 msgid "flash.friend_added" msgstr "" -#: fietsboek/views/user_data.py:103 +#: fietsboek/views/user_data.py:109 msgid "flash.friend_request_sent" msgstr "" +#: fietsboek/views/user_data.py:195 +msgid "flash.sessions_logged_out" +msgstr "" + diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index 432c61d..36cdbc9 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -59,6 +59,8 @@ SCRYPT_PARAMETERS = { "p": 1, } SALT_LENGTH = 32 +SESSION_SECRET_LENGTH = 32 +"""Length of the secret bytes for the user's session.""" TOKEN_LIFETIME = datetime.timedelta(hours=24) """Maximum validity time of a token.""" @@ -90,6 +92,8 @@ class User(Base): :vartype salt: bytes :ivar email: Email address of the user. :vartype email: str + :ivar session_secret: Secret bytes for the session fingerprint. + :vartype session_secret: bytes :ivar is_admin: Flag determining whether this user has admin access. :vartype is_admin: bool :ivar is_verified: Flag determining whether this user has been verified. @@ -112,6 +116,7 @@ class User(Base): password = Column(LargeBinary) salt = Column(LargeBinary) email = Column(Text) + session_secret = Column(LargeBinary(SESSION_SECRET_LENGTH)) is_admin = Column(Boolean, default=False) is_verified = Column(Boolean, default=False) @@ -187,6 +192,17 @@ class User(Base): ] return acl + def _fingerprint(self) -> str: + # We're not interested in hiding the data by all means, we just want a + # good check to see whether the email of the represented user actually + # matches with the database entry. Therefore, we use a short SHAKE hash. + email = self.email or "" + shaker = hashlib.shake_128() + shaker.update(email.encode("utf-8")) + shaker.update(self.password or b"") + shaker.update(self.session_secret or b"") + return shaker.hexdigest(FINGERPRINT_SHAKE_BYTES) + def authenticated_user_id(self) -> str: """Returns a string suitable to re-identify this user, e.g. in a cookie or session. @@ -196,12 +212,7 @@ class User(Base): :return: The string used to re-identify this user. """ - # We're not interested in hiding the email by all means, we just want a - # good check to see whether the email of the represented user actually - # matches with the database entry. Therefore, we use a short SHAKE hash. - email = self.email or "" - shaker = hashlib.shake_128(email.encode("utf-8")) - fingerprint = shaker.hexdigest(FINGERPRINT_SHAKE_BYTES) + fingerprint = self._fingerprint() return f"{self.id}-{fingerprint}" @classmethod @@ -229,11 +240,8 @@ class User(Base): user = session.execute(query).scalar_one() except NoResultFound: return None - email = user.email or "" - user_fingerprint = hashlib.shake_128(email.encode("utf-8")).hexdigest( - FINGERPRINT_SHAKE_BYTES - ) - if user_fingerprint != fingerprint: + # pylint: disable=protected-access + if user._fingerprint() != fingerprint: return None return user @@ -270,6 +278,14 @@ class User(Base): except InvalidKey: raise PasswordMismatch from None + def roll_session_secret(self): + """Rolls a new session secret for the user. + + This function automatically generates the right amount of random bytes + from a secure source. + """ + self.session_secret = secrets.token_bytes(SESSION_SECRET_LENGTH) + def principals(self): """Returns all principals that this user fulfills. diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 36233e6..480094c 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -60,6 +60,7 @@ def includeme(config): config.add_route("accept-friend", "/me/accept-friend") config.add_route("json-friends", "/me/friends.json") config.add_route("toggle-favourite", "/me/toggle-favourite") + config.add_route("force-logout", "/me/force-logout") config.add_route("profile", "/user/{user_id}", factory="fietsboek.models.User.factory") config.add_route( diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 3e987d5..d0b5639 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -111,6 +111,7 @@ def cmd_user_add( user = models.User(name=name, email=email, is_verified=True, is_admin=admin) user.set_password(password) + user.roll_session_secret() with env["request"].tm: dbsession = env["request"].dbsession diff --git a/fietsboek/templates/user_data.jinja2 b/fietsboek/templates/user_data.jinja2 index 15588e8..59124ea 100644 --- a/fietsboek/templates/user_data.jinja2 +++ b/fietsboek/templates/user_data.jinja2 @@ -35,6 +35,17 @@ <hr> + <h2>{{ _("page.my_profile.session_logout.title") }}</h2> + + <p>{{ _("page.my_profile.session_logout.explanation") }}</p> + + <form method="POST" action="{{ request.route_path('force-logout') }}"> + {{ util.hidden_csrf_input() }} + <button type="submit" class="btn btn-danger"><i class="bi bi-shield-lock-fill"></i> {{ _("page.my_profile.session_logout.button") }}</button> + </form> + + <hr> + <h2>{{ _("page.my_profile.friends") }}</h2> <ul class="list-group"> diff --git a/fietsboek/views/account.py b/fietsboek/views/account.py index 5400f0a..e353360 100644 --- a/fietsboek/views/account.py +++ b/fietsboek/views/account.py @@ -60,6 +60,7 @@ def do_create_account(request): user = models.User(name=name, email=email_addr) user.set_password(password) + user.roll_session_secret() request.dbsession.add(user) actions.send_verification_token(request, user) diff --git a/fietsboek/views/user_data.py b/fietsboek/views/user_data.py index a7680e4..66c2075 100644 --- a/fietsboek/views/user_data.py +++ b/fietsboek/views/user_data.py @@ -4,6 +4,8 @@ import datetime from pyramid.httpexceptions import HTTPForbidden, HTTPFound, HTTPNotFound from pyramid.i18n import TranslationString as _ from pyramid.request import Request +from pyramid.response import Response +from pyramid.security import remember from pyramid.view import view_config from sqlalchemy import select @@ -48,18 +50,22 @@ def do_change_profile(request): :rtype: pyramid.response.Response """ password = request.params["password"] + # Save the identity as request.identity will be None after changing the + # password. + identity = request.identity if password: try: util.check_password_constraints(password, request.params["repeat-password"]) except ValueError as exc: request.session.flash(request.localizer.translate(exc.args[0])) return HTTPFound(request.route_url("user-data")) - request.identity.set_password(request.params["password"]) + identity.set_password(request.params["password"]) name = request.params["name"] - if request.identity.name != name: - request.identity.name = name + if identity.name != name: + identity.name = name request.session.flash(request.localizer.translate(_("flash.personal_data_updated"))) - return HTTPFound(request.route_url("user-data")) + headers = remember(request, identity.authenticated_user_id()) + return HTTPFound(request.route_url("user-data"), headers=headers) @view_config(route_name="add-friend", permission="user", request_method="POST") @@ -176,3 +182,15 @@ def do_toggle_favourite(request: Request) -> dict: return HTTPNotFound() request.identity.toggle_favourite(track) return {"favourite": request.identity in track.favourees} + + +@view_config(route_name="force-logout", permission="user", request_method="POST") +def do_force_logout(request: Request) -> Response: + """Forces all sessions to be logged out. + + :param request: The Pyramid request. + :return: The HTTP response. + """ + request.identity.roll_session_secret() + request.session.flash(request.localizer.translate(_("flash.sessions_logged_out"))) + return HTTPFound(request.route_url("login")) diff --git a/tests/playwright/conftest.py b/tests/playwright/conftest.py index b17d457..aefe1ee 100644 --- a/tests/playwright/conftest.py +++ b/tests/playwright/conftest.py @@ -81,7 +81,7 @@ class Helper: user.set_password("password") self.dbaccess.add(user) self.dbaccess.commit() - self.dbaccess.refresh(user, ["id", "email"]) + self.dbaccess.refresh(user, ["id", "email", "password", "session_secret"]) self.dbaccess.expunge(user) self._johnny = user return user |