aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2023-10-15 00:06:09 +0200
committerDaniel Schadt <kingdread@gmx.de>2023-10-15 00:06:09 +0200
commitfcb0f5cd199e68fff502943e048db2d0ac93503a (patch)
tree82e27ca1a86c0b59c0201753732562f3037efcc8
parentab37c2dcbcc6916d38ecc2c3e59d9c94711e52fb (diff)
parentbae6499475d0f9f735caebeda56755183ced5aeb (diff)
downloadfietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.tar.gz
fietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.tar.bz2
fietsboek-fcb0f5cd199e68fff502943e048db2d0ac93503a.zip
Merge branch 'session-secrets'
-rw-r--r--doc/developer.rst1
-rw-r--r--doc/developer/authentication.rst115
-rw-r--r--fietsboek/alembic/versions/20230914_4566843039d6.py37
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin14714 -> 15259 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po41
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin13738 -> 14269 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po41
-rw-r--r--fietsboek/locale/fietslog.pot38
-rw-r--r--fietsboek/models/user.py38
-rw-r--r--fietsboek/routes.py1
-rw-r--r--fietsboek/scripts/fietsctl.py1
-rw-r--r--fietsboek/templates/user_data.jinja211
-rw-r--r--fietsboek/views/account.py1
-rw-r--r--fietsboek/views/user_data.py26
-rw-r--r--tests/playwright/conftest.py2
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
index e6dd1c6..f2bf3ea 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
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
index 56137c1..da23e31 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
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