diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 44270012..326b851d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,7 @@ This document describes changes between each past release.
Breaking changes
----------------
+- Enable session cookie security by default (#845)
- Drop support for Python 2 (#483)
- Drop support for Python 3.5 (#571)
- Drop support for MySQL (#743)
@@ -25,6 +26,7 @@ Security
- Add CSRF validation on destructive actions (#796)
- Ask for private code to delete project or project history (#796)
+- Add headers to mitigate Clickjacking, XSS, and other attacks: `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`, `Content-Security-Policy`, `Referrer-Policy` (#845)
Added
-----
@@ -37,7 +39,6 @@ Added
- Add sorting, pagination, and searching to the admin dashboard (#538)
- Add Project History page that records all changes (#553)
- Add token-based authentication to the API (#504)
-- Add translations for Hindi, Portuguese (Brazil), Tamil
- Add illustrations as a showcase, currently only for French (#544)
- Add a page for downloading mobile application (#688)
- Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish
diff --git a/conf/entrypoint.sh b/conf/entrypoint.sh
index dfe5e12c..e66ad7c8 100755
--- a/conf/entrypoint.sh
+++ b/conf/entrypoint.sh
@@ -21,6 +21,7 @@ ADMIN_PASSWORD = '$ADMIN_PASSWORD'
ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION
ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD
BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE"
+SESSION_COOKIE_SECURE = $SESSION_COOKIE_SECURE
EOF
# Start gunicorn without forking
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 0ba3678b..6b35f326 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -13,6 +13,21 @@ To know defaults on your deployed instance, simply look at your
"Production values" are the recommended values for use in production.
+Configuration files
+-------------------
+
+By default, Ihatemoney loads its configuration from ``/etc/ihatemoney/ihatemoney.cfg``.
+
+If you need to load the configuration from a custom path, you can define the
+``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable with the path to the configuration
+file.
+For instance ::
+
+ export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"
+
+The path should be absolute. A relative path will be interpreted as being
+inside ``/etc/ihatemoney/``.
+
`SQLALCHEMY_DATABASE_URI`
-------------------------
@@ -49,6 +64,21 @@ of the secret key could easily access any project and bypass the private code ve
- **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to
something random, which is good.
+`SESSION_COOKIE_SECURE`
+-----------------------
+
+A boolean that controls whether the session cookie will be marked "secure".
+If this is the case, browsers will refuse to send the session cookie over plain HTTP.
+
+- **Default value:** ``True``
+- **Production value:** ``True`` if you run your service over HTTPS, ``False`` if you run
+ your service over plain HTTP.
+
+Note: this setting is actually interpreted by Flask, see the
+`Flask documentation`_ for details.
+
+.. _Flask documentation: https://flask.palletsprojects.com/en/2.0.x/config/#SESSION_COOKIE_SECURE
+
`MAIL_DEFAULT_SENDER`
---------------------
@@ -148,12 +178,3 @@ possible to configure it to act differently, thanks to the great
* **MAIL_PASSWORD** : default **None**
* **DEFAULT_MAIL_SENDER** : default **None**
-Using an alternate settings path
---------------------------------
-
-You can put your settings file where you want, and pass its path to the
-application using the ``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable.
-
-For instance ::
-
- export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg"
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 80174b76..5d597b25 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -104,12 +104,20 @@ You can create a ``settings.cfg`` file, with the following content::
DEBUG = True
SQLACHEMY_ECHO = DEBUG
-You can also set the `TESTING` flag to `True` so no mails are sent
-(and no exception is raised) while you're on development mode.
Then before running the application, declare its path with ::
export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg"
+You can also set the ``TESTING`` flag to ``True`` so no mails are sent
+(and no exception is raised) while you're on development mode.
+
+In some cases, you may need to disable secure cookies by setting
+``SESSION_COOKIE_SECURE`` to ``False``. This is needed if you
+access your dev server over the network: with the default value
+of ``SESSION_COOKIE_SECURE``, the browser will refuse to send
+the session cookie over insecure HTTP, so many features of Ihatemoney
+won't work (project login, language change, etc).
+
.. _contributing-developer:
Contributing as a developer
diff --git a/docs/upgrade.rst b/docs/upgrade.rst
index ec846324..53185413 100644
--- a/docs/upgrade.rst
+++ b/docs/upgrade.rst
@@ -65,6 +65,17 @@ If so, pick the ``pip`` commands to use in the relevant section(s) of
Then follow :ref:`general-procedure` from step 1. in order to complete the update.
+Disable session cookie security if running over plain HTTP
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+
+.. note:: If you are running Ihatemoney over HTTPS, no special action is required.
+
+Session cookies are now marked "secure" by default to increase security.
+
+If you run Ihatemoney over plain HTTP, you need to explicitly disable this security
+feature by setting ``SESSION_COOKIE_SECURE`` to ``False``, see :ref:`configuration`.
+
+
Switch to MariaDB >= 10.3.2 instead of MySQL
++++++++++++++++++++++++++++++++++++++++++++
diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py
index ede76e46..fa097dec 100644
--- a/ihatemoney/api/common.py
+++ b/ihatemoney/api/common.py
@@ -35,7 +35,9 @@ def need_auth(f):
auth_token = auth_header.split(" ")[1]
except IndexError:
abort(401)
- project_id = Project.verify_token(auth_token, token_type="non_timed_token")
+ project_id = Project.verify_token(
+ auth_token, token_type="auth", project_id=project_id
+ )
if auth_token and project_id:
project = Project.query.get(project_id)
if project:
diff --git a/ihatemoney/conf-templates/ihatemoney.cfg.j2 b/ihatemoney/conf-templates/ihatemoney.cfg.j2
index 474fbaca..82f7114c 100644
--- a/ihatemoney/conf-templates/ihatemoney.cfg.j2
+++ b/ihatemoney/conf-templates/ihatemoney.cfg.j2
@@ -44,3 +44,7 @@ ACTIVATE_ADMIN_DASHBOARD = False
# ENABLE_RECAPTCHA = True
# RECAPTCHA_PUBLIC_KEY = ""
# RECAPTCHA_PRIVATE_KEY = ""
+
+# Enable secure cookies. Requires HTTPS. Disable if you run your ihatemoney
+# service over plain HTTP.
+SESSION_COOKIE_SECURE = True
diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py
index 5dfc3c90..9cf88af2 100644
--- a/ihatemoney/default_settings.py
+++ b/ihatemoney/default_settings.py
@@ -8,6 +8,7 @@ ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = ""
ALLOW_PUBLIC_PROJECT_CREATION = True
ACTIVATE_ADMIN_DASHBOARD = False
+SESSION_COOKIE_SECURE = True
SUPPORTED_LANGUAGES = [
"de",
"el",
diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py
index dc282225..bbc50290 100644
--- a/ihatemoney/forms.py
+++ b/ihatemoney/forms.py
@@ -13,6 +13,7 @@ from wtforms.fields.core import Label, SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import (
+ URL,
DataRequired,
Email,
EqualTo,
@@ -292,7 +293,7 @@ class BillForm(FlaskForm):
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
external_link = URLField(
_("External link"),
- validators=[Optional()],
+ validators=[Optional(), URL()],
description=_("A link to an external document, related to this bill"),
)
payed_for = SelectMultipleField(
diff --git a/ihatemoney/models.py b/ihatemoney/models.py
index 68d1fd84..b3d1cba2 100644
--- a/ihatemoney/models.py
+++ b/ihatemoney/models.py
@@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy
from itsdangerous import (
BadSignature,
SignatureExpired,
- TimedJSONWebSignatureSerializer,
URLSafeSerializer,
+ URLSafeTimedSerializer,
)
import sqlalchemy
from sqlalchemy import orm
@@ -339,41 +339,61 @@ class Project(db.Model):
db.session.delete(self)
db.session.commit()
- def generate_token(self, expiration=0):
+ def generate_token(self, token_type="auth"):
"""Generate a timed and serialized JsonWebToken
- :param expiration: Token expiration time (in seconds)
+ :param token_type: Either "auth" for authentication (invalidated when project code changed),
+ or "reset" for password reset (invalidated after expiration)
"""
- if expiration:
- serializer = TimedJSONWebSignatureSerializer(
- current_app.config["SECRET_KEY"], expiration
+
+ if token_type == "reset":
+ serializer = URLSafeTimedSerializer(
+ current_app.config["SECRET_KEY"], salt=token_type
)
- token = serializer.dumps({"project_id": self.id}).decode("utf-8")
+ token = serializer.dumps([self.id])
else:
- serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
- token = serializer.dumps({"project_id": self.id})
+ serializer = URLSafeSerializer(
+ current_app.config["SECRET_KEY"] + self.password, salt=token_type
+ )
+ token = serializer.dumps([self.id])
+
return token
@staticmethod
- def verify_token(token, token_type="timed_token"):
+ def verify_token(token, token_type="auth", project_id=None, max_age=3600):
"""Return the project id associated to the provided token,
None if the provided token is expired or not valid.
:param token: Serialized TimedJsonWebToken
+ :param token_type: Either "auth" for authentication (invalidated when project code changed),
+ or "reset" for password reset (invalidated after expiration)
+ :param project_id: Project ID. Used for token_type "auth" to use the password as serializer
+ secret key.
+ :param max_age: Token expiration time (in seconds). Only used with token_type "reset"
"""
- if token_type == "timed_token":
- serializer = TimedJSONWebSignatureSerializer(
- current_app.config["SECRET_KEY"]
+ loads_kwargs = {}
+ if token_type == "reset":
+ serializer = URLSafeTimedSerializer(
+ current_app.config["SECRET_KEY"], salt=token_type
)
+ loads_kwargs["max_age"] = max_age
else:
- serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
+ project = Project.query.get(project_id) if project_id is not None else None
+ password = project.password if project is not None else ""
+ serializer = URLSafeSerializer(
+ current_app.config["SECRET_KEY"] + password, salt=token_type
+ )
try:
- data = serializer.loads(token)
+ data = serializer.loads(token, **loads_kwargs)
except SignatureExpired:
return None
except BadSignature:
return None
- return data["project_id"]
+
+ data_project = data[0] if isinstance(data, list) else None
+ return (
+ data_project if project_id is None or data_project == project_id else None
+ )
def __str__(self):
return self.name
diff --git a/ihatemoney/run.py b/ihatemoney/run.py
index c8fc5b25..cea6f93e 100644
--- a/ihatemoney/run.py
+++ b/ihatemoney/run.py
@@ -7,6 +7,7 @@ from flask import Flask, g, render_template, request, session
from flask_babel import Babel, format_currency
from flask_mail import Mail
from flask_migrate import Migrate, stamp, upgrade
+from flask_talisman import Talisman
from jinja2 import pass_context
from markupsafe import Markup
import pytz
@@ -126,6 +127,24 @@ def create_app(
instance_relative_config=instance_relative_config,
)
+ # If we need to load external JS/CSS/image resources, it needs to be added here, see
+ # https://github.com/wntrblm/flask-talisman#content-security-policy
+ csp = {
+ "default-src": ["'self'"],
+ # We have several inline javascript scripts :(
+ "script-src": ["'self'", "'unsafe-inline'"],
+ "object-src": "'none'",
+ }
+
+ Talisman(
+ app,
+ # Forcing HTTPS is the job of a reverse proxy
+ force_https=False,
+ # This is handled separately through the SESSION_COOKIE_SECURE Flask setting
+ session_cookie_secure=False,
+ content_security_policy=csp,
+ )
+
# If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration)
app.wsgi_app = PrefixedWSGI(app)
diff --git a/ihatemoney/templates/invitation_mail.en.j2 b/ihatemoney/templates/invitation_mail.en.j2
index 79fcc427..2b3157b1 100644
--- a/ihatemoney/templates/invitation_mail.en.j2
+++ b/ihatemoney/templates/invitation_mail.en.j2
@@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha
It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest.
-You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
+You can log in using this link: {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }}
If your cookie gets deleted or if you log out, you will need to log back in using the first link.
diff --git a/ihatemoney/templates/invitation_mail.fr.j2 b/ihatemoney/templates/invitation_mail.fr.j2
index e57d7035..d095cfdb 100644
--- a/ihatemoney/templates/invitation_mail.fr.j2
+++ b/ihatemoney/templates/invitation_mail.fr.j2
@@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité
Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe du reste.
-Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}.
+Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}.
Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }}
Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien.
diff --git a/ihatemoney/templates/password_reminder.en.j2 b/ihatemoney/templates/password_reminder.en.j2
index c6543546..845ff790 100644
--- a/ihatemoney/templates/password_reminder.en.j2
+++ b/ihatemoney/templates/password_reminder.en.j2
@@ -1,7 +1,7 @@
Hi,
You requested to reset the password of the following project: "{{ project.name }}".
-You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
+You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
This link is only valid for one hour.
Hope this helps,
diff --git a/ihatemoney/templates/password_reminder.fr.j2 b/ihatemoney/templates/password_reminder.fr.j2
index 17c52c4d..4603a963 100644
--- a/ihatemoney/templates/password_reminder.fr.j2
+++ b/ihatemoney/templates/password_reminder.fr.j2
@@ -1,7 +1,7 @@
Salut,
Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}".
-Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}.
+Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}.
Ce lien est seulement valide pendant 1 heure.
Faites-en bon usage !
diff --git a/ihatemoney/templates/send_invites.html b/ihatemoney/templates/send_invites.html
index 53492c85..8b73b175 100644
--- a/ihatemoney/templates/send_invites.html
+++ b/ihatemoney/templates/send_invites.html
@@ -21,8 +21,8 @@
{{ _("You can directly share the following link via your prefered medium") }}
-
- {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}
+
+ {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
|
diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py
index 41f5ab2d..83d5aa2a 100644
--- a/ihatemoney/tests/api_test.py
+++ b/ihatemoney/tests/api_test.py
@@ -213,7 +213,9 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette/token", headers=self.get_auth("raclette")
)
decoded_resp = json.loads(resp.data.decode("utf-8"))
- resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"]))
+ resp = self.client.get(
+ f"/authenticate?token={decoded_resp['token']}&project_id=raclette"
+ )
# Test that we are redirected.
self.assertEqual(302, resp.status_code)
diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py
index 75a2dc35..1539ece7 100644
--- a/ihatemoney/tests/budget_test.py
+++ b/ihatemoney/tests/budget_test.py
@@ -4,6 +4,7 @@ import json
import re
from time import sleep
import unittest
+from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from flask import session
import pytest
@@ -11,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
+from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode
@@ -87,11 +89,52 @@ class BudgetTestCase(IhatemoneyTestCase):
)
# Test empty and invalid tokens
self.client.get("/exit")
+ # Use another project_id
+ parsed_url = urlparse(url)
+ query = parse_qs(parsed_url.query)
+ query["project_id"] = "invalid"
+ resp = self.client.get(
+ urlunparse(parsed_url._replace(query=urlencode(query, doseq=True)))
+ )
+ assert "You either provided a bad token" in resp.data.decode("utf-8")
+
resp = self.client.get("/authenticate")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
resp = self.client.get("/authenticate?token=token")
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
+ def test_invite_code_invalidation(self):
+ """Test that invitation link expire after code change"""
+ self.login("raclette")
+ self.post_project("raclette")
+ response = self.client.get("/raclette/invite").data.decode("utf-8")
+ link = extract_link(response, "share the following link")
+
+ self.client.get("/exit")
+ response = self.client.get(link)
+ # Link is valid
+ assert response.status_code == 302
+
+ # Change password to invalidate token
+ # Other data are required, but useless for the test
+ response = self.client.post(
+ "/raclette/edit",
+ data={
+ "name": "raclette",
+ "contact_email": "zorglub@notmyidea.org",
+ "password": "didoudida",
+ "default_currency": "XXX",
+ },
+ follow_redirects=True,
+ )
+ assert response.status_code == 200
+ assert "alert-danger" not in response.data.decode("utf-8")
+
+ self.client.get("/exit")
+ response = self.client.get(link, follow_redirects=True)
+ # Link is invalid
+ self.assertIn("You either provided a bad token", response.data.decode("utf-8"))
+
def test_password_reminder(self):
# test that it is possible to have an email containing the password of a
# project in case people forget it (and it happens!)
@@ -632,6 +675,35 @@ class BudgetTestCase(IhatemoneyTestCase):
bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0]
self.assertEqual(bill.amount, 25.02)
+ # add a bill with a valid external link
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2015-05-05",
+ "what": "fromage à raclette",
+ "payer": members_ids[0],
+ "payed_for": members_ids,
+ "amount": "42",
+ "external_link": "https://example.com/fromage",
+ },
+ )
+ bill = models.Bill.query.filter(models.Bill.date == "2015-05-05")[0]
+ self.assertEqual(bill.external_link, "https://example.com/fromage")
+
+ # add a bill with an invalid external link
+ resp = self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2015-05-06",
+ "what": "mauvais fromage à raclette",
+ "payer": members_ids[0],
+ "payed_for": members_ids,
+ "amount": "42000",
+ "external_link": "javascript:alert('Tu bluffes, Martoni.')",
+ },
+ )
+ self.assertIn("Invalid URL", resp.data.decode("utf-8"))
+
def test_weighted_balance(self):
self.post_project("raclette")
diff --git a/ihatemoney/tests/common/help_functions.py b/ihatemoney/tests/common/help_functions.py
index e9c4dcd1..5a401059 100644
--- a/ihatemoney/tests/common/help_functions.py
+++ b/ihatemoney/tests/common/help_functions.py
@@ -1,5 +1,16 @@
+from markupsafe import Markup
+
+
def em_surround(string, regex_escape=False):
if regex_escape:
return r'%s<\/em>' % string
else:
return '%s' % string
+
+
+def extract_link(data, start_prefix):
+ base_index = data.find(start_prefix)
+ start = data.find('href="', base_index) + 6
+ end = data.find('">', base_index)
+ link = Markup(data[start:end]).unescape()
+ return link
diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.mo b/ihatemoney/translations/de/LC_MESSAGES/messages.mo
index 27d5ffa3..0c14afc6 100644
Binary files a/ihatemoney/translations/de/LC_MESSAGES/messages.mo and b/ihatemoney/translations/de/LC_MESSAGES/messages.mo differ
diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.po b/ihatemoney/translations/de/LC_MESSAGES/messages.po
index 46144d0d..60be7506 100644
--- a/ihatemoney/translations/de/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/de/LC_MESSAGES/messages.po
@@ -3,8 +3,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
-"PO-Revision-Date: 2021-08-19 20:34+0000\n"
-"Last-Translator: corny \n"
+"PO-Revision-Date: 2021-09-23 19:36+0000\n"
+"Last-Translator: Christian H. \n"
"Language-Team: German \n"
"Language: de\n"
@@ -12,7 +12,7 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"X-Generator: Weblate 4.8-dev\n"
+"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n"
msgid ""
@@ -74,7 +74,7 @@ msgstr ""
"wähle eine andere Kennung"
msgid "Enter private code to confirm deletion"
-msgstr ""
+msgstr "Geben Sie Ihren privaten Code ein, um die Löschung zu bestätigen"
msgid "Unknown error"
msgstr "Unbekannter Fehler"
diff --git a/ihatemoney/translations/eo/LC_MESSAGES/messages.mo b/ihatemoney/translations/eo/LC_MESSAGES/messages.mo
index 5be5c4a5..cc3133f3 100644
Binary files a/ihatemoney/translations/eo/LC_MESSAGES/messages.mo and b/ihatemoney/translations/eo/LC_MESSAGES/messages.mo differ
diff --git a/ihatemoney/translations/eo/LC_MESSAGES/messages.po b/ihatemoney/translations/eo/LC_MESSAGES/messages.po
index c9c0d740..02e59259 100644
--- a/ihatemoney/translations/eo/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/eo/LC_MESSAGES/messages.po
@@ -1,18 +1,18 @@
-
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
-"PO-Revision-Date: 2021-07-10 15:34+0000\n"
+"PO-Revision-Date: 2021-10-01 20:35+0000\n"
"Last-Translator: phlostically \n"
+"Language-Team: Esperanto \n"
"Language: eo\n"
-"Language-Team: Esperanto \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"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n"
msgid ""
@@ -76,13 +76,11 @@ msgstr ""
msgid "Enter private code to confirm deletion"
msgstr ""
-#, fuzzy
msgid "Unknown error"
-msgstr "Nekonata projekto"
+msgstr "Nekonata eraro"
-#, fuzzy
msgid "Invalid private code."
-msgstr "Nova privata kodo"
+msgstr "Nevalida privata kodo."
msgid "Get in"
msgstr "Eniri"
@@ -175,30 +173,30 @@ msgstr "La retpoŝta adreso %(email)s ne validas"
#. List with two items only
msgid "{dual_object_0} and {dual_object_1}"
-msgstr ""
+msgstr "{dual_object_0} kaj {dual_object_1}"
#. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}"
-msgstr ""
+msgstr "{previous_object} kaj {end_object}"
#. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}"
-msgstr ""
+msgstr "{previous_object}, {next_object}"
#. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}"
-msgstr ""
+msgstr "{start_object}, {next_object}"
msgid "No Currency"
msgstr "Neniu valuto"
#. Form error with only one error
msgid "{prefix}: {error}"
-msgstr ""
+msgstr "{prefix}: {error}"
#. Form error with a list of errors
msgid "{prefix}:
{errors}"
-msgstr ""
+msgstr "{prefix}:
{errors}"
msgid "Too many failed login attempts, please retry later."
msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste."
@@ -396,16 +394,14 @@ msgstr "Elŝuti programon por poŝaparato"
msgid "Get it on"
msgstr "Elŝuti ĝin ĉe"
-#, fuzzy
msgid "Are you sure?"
-msgstr "ĉu vi certas?"
+msgstr "Ĉu vi certas?"
msgid "Edit project"
msgstr "Redakti projekton"
-#, fuzzy
msgid "Delete project"
-msgstr "Redakti projekton"
+msgstr "Forviŝi projekton"
msgid "Import JSON"
msgstr "Enporti JSON-dosieron"
@@ -453,7 +449,7 @@ msgid "Everyone"
msgstr "Ĉiuj"
msgid "No one"
-msgstr ""
+msgstr "Neniu"
msgid "More options"
msgstr ""
diff --git a/ihatemoney/translations/ru/LC_MESSAGES/messages.mo b/ihatemoney/translations/ru/LC_MESSAGES/messages.mo
index c9c234e0..c343e8df 100644
Binary files a/ihatemoney/translations/ru/LC_MESSAGES/messages.mo and b/ihatemoney/translations/ru/LC_MESSAGES/messages.mo differ
diff --git a/ihatemoney/translations/ru/LC_MESSAGES/messages.po b/ihatemoney/translations/ru/LC_MESSAGES/messages.po
index 0b307d94..24845770 100644
--- a/ihatemoney/translations/ru/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/ru/LC_MESSAGES/messages.po
@@ -1,19 +1,19 @@
-
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
-"PO-Revision-Date: 2021-05-10 11:33+0000\n"
-"Last-Translator: Vsevolod \n"
+"PO-Revision-Date: 2021-09-20 12:38+0000\n"
+"Last-Translator: Роман Прокопов \n"
+"Language-Team: Russian \n"
"Language: ru\n"
-"Language-Team: Russian \n"
-"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
-"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
+"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n"
msgid ""
@@ -26,9 +26,8 @@ msgstr ""
msgid "Project name"
msgstr "Имя проекта"
-#, fuzzy
msgid "New private code"
-msgstr "Приватный код"
+msgstr "Новый приватный код"
msgid "Enter a new code if you want to change it"
msgstr ""
diff --git a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo
index b2ff6719..969a2e23 100644
Binary files a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo and b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo differ
diff --git a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po
index 26bb5077..3a790c3f 100644
--- a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po
@@ -1,19 +1,18 @@
-
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-07-17 17:31+0200\n"
-"PO-Revision-Date: 2020-10-12 04:47+0000\n"
-"Last-Translator: Jwen921 \n"
+"PO-Revision-Date: 2021-10-10 05:05+0000\n"
+"Last-Translator: Frank.wu \n"
+"Language-Team: Chinese (Simplified) \n"
"Language: zh_Hans\n"
-"Language-Team: Chinese (Simplified) "
-""
-"\n"
-"Plural-Forms: nplurals=1; plural=0\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Generator: Weblate 4.9-dev\n"
"Generated-By: Babel 2.9.0\n"
msgid ""
@@ -24,12 +23,11 @@ msgstr "金额或符号无效。仅限数字与+-*/符号。"
msgid "Project name"
msgstr "账目名称"
-#, fuzzy
msgid "New private code"
-msgstr "共享密钥"
+msgstr "新的私人代码"
msgid "Enter a new code if you want to change it"
-msgstr ""
+msgstr "如要更改,请输入新代码"
msgid "Email"
msgstr "邮箱"
@@ -46,7 +44,7 @@ msgstr "默认货币"
msgid ""
"This project cannot be set to 'no currency' because it contains bills in "
"multiple currencies."
-msgstr ""
+msgstr "此项目不能设置为“无货币”,因为它包含多种货币的账单。"
msgid "Import previously exported JSON file"
msgstr "导入之前的JSON 文件"
@@ -70,15 +68,13 @@ msgid ""
msgstr "账目(“%(project)s”)已存在,请选择一个新名称"
msgid "Enter private code to confirm deletion"
-msgstr ""
+msgstr "请输入专用代码以确认删除"
-#, fuzzy
msgid "Unknown error"
-msgstr "未知项目"
+msgstr "未知错误"
-#, fuzzy
msgid "Invalid private code."
-msgstr "共享密钥"
+msgstr "无效的私人代码。"
msgid "Get in"
msgstr "进入"
@@ -171,40 +167,40 @@ msgstr "此邮箱%(email)s不存在"
#. List with two items only
msgid "{dual_object_0} and {dual_object_1}"
-msgstr ""
+msgstr "{dual_object_0} 和 {dual_object_1}"
#. Last two items of a list with more than 3 items
msgid "{previous_object}, and {end_object}"
-msgstr ""
+msgstr "{previous_object} 和 {end_object}"
#. Two items in a middle of a list with more than 5 objects
msgid "{previous_object}, {next_object}"
-msgstr ""
+msgstr "{previous_object},{next_object}"
#. First two items of a list with more than 3 items
msgid "{start_object}, {next_object}"
-msgstr ""
+msgstr "{start_object}, {next_object}"
msgid "No Currency"
-msgstr "没有货币"
+msgstr "无货币"
#. Form error with only one error
msgid "{prefix}: {error}"
-msgstr ""
+msgstr "{prefix}: {error}"
#. Form error with a list of errors
msgid "{prefix}:
{errors}"
-msgstr ""
+msgstr "{prefix}:
{errors}"
msgid "Too many failed login attempts, please retry later."
-msgstr "输入错误太多次了,请稍后重试。"
+msgstr "登录失败次数过多,请稍后重试。"
#, python-format
msgid "This admin password is not the right one. Only %(num)d attempts left."
-msgstr "管理密码有误,只剩 %(num)d次尝试机会"
+msgstr "管理密码有误,只剩 %(num)d次尝试机会。"
msgid "You either provided a bad token or no project identifier."
-msgstr "你输入了错误的符号或没有项目标识符。"
+msgstr "你输入了错误的令牌或没有项目标识符。"
msgid "This private code is not the right one"
msgstr "专用码不正确"
@@ -241,7 +237,7 @@ msgid "Unknown project"
msgstr "未知项目"
msgid "Password successfully reset."
-msgstr "密码重置成功"
+msgstr "密码重置成功。"
msgid "Project successfully uploaded"
msgstr "项目成功上传"
@@ -253,7 +249,7 @@ msgid "Project successfully deleted"
msgstr "项目成功删除"
msgid "Error deleting project"
-msgstr ""
+msgstr "删除项目时出错"
#, python-format
msgid "You have been invited to share your expenses for %(project)s"
@@ -273,20 +269,20 @@ msgid "%(member)s has been added"
msgstr "已添加%(member)s"
msgid "Error activating member"
-msgstr ""
+msgstr "激活成员时出错"
#, python-format
msgid "%(name)s is part of this project again"
msgstr "%(name)s 已经在项目里了"
msgid "Error removing member"
-msgstr ""
+msgstr "删除成员时出错"
#, python-format
msgid ""
"User '%(name)s' has been deactivated. It will still appear in the users "
"list until its balance becomes zero."
-msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里"
+msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里。"
#, python-format
msgid "User '%(name)s' has been removed"
@@ -300,7 +296,7 @@ msgid "The bill has been added"
msgstr "帐单已添加"
msgid "Error deleting bill"
-msgstr ""
+msgstr "删除账单时出错"
msgid "The bill has been deleted"
msgstr "账单已删除"
@@ -308,26 +304,23 @@ msgstr "账单已删除"
msgid "The bill has been modified"
msgstr "帐单已修改"
-#, fuzzy
msgid "Error deleting project history"
-msgstr "启用项目历史"
+msgstr "删除项目历史记录时出错"
-#, fuzzy
msgid "Deleted project history."
-msgstr "启用项目历史"
+msgstr "已删除的项目历史记录。"
-#, fuzzy
msgid "Error deleting recorded IP addresses"
-msgstr "删除已储存的IP地址"
+msgstr "删除记录的IP地址时出错"
msgid "Deleted recorded IP addresses in project history."
-msgstr ""
+msgstr "删除项目历史记录中的 IP 地址。"
msgid "Sorry, we were unable to find the page you've asked for."
-msgstr "对不起,未找到该页面"
+msgstr "抱歉,我们无法找到您要求的页面。"
msgid "The best thing to do is probably to get back to the main page."
-msgstr "最好的办法是返回主页"
+msgstr "最好的办法是返回主页。"
msgid "Back to the list"
msgstr "返回列表"
@@ -375,26 +368,22 @@ msgid "show"
msgstr "显示"
msgid "The Dashboard is currently deactivated."
-msgstr "操作面板失效"
+msgstr "操作面板失效。"
-#, fuzzy
msgid "Download Mobile Application"
-msgstr "手机软件"
+msgstr "下载移动应用程序"
-#, fuzzy
msgid "Get it on"
-msgstr "进入"
+msgstr "获取"
-#, fuzzy
msgid "Are you sure?"
-msgstr "确定?"
+msgstr "是否确定?"
msgid "Edit project"
msgstr "编辑项目"
-#, fuzzy
msgid "Delete project"
-msgstr "编辑项目"
+msgstr "删除项目"
msgid "Import JSON"
msgstr "导入json文件"
@@ -430,7 +419,7 @@ msgid "Edit the project"
msgstr "编辑项目"
msgid "This will remove all bills and participants in this project!"
-msgstr ""
+msgstr "这将删除此项目的所有账单和参与者!"
msgid "Edit this bill"
msgstr "编辑帐单"
@@ -442,10 +431,10 @@ msgid "Everyone"
msgstr "每个人"
msgid "No one"
-msgstr ""
+msgstr "无人"
msgid "More options"
-msgstr ""
+msgstr "更多选项"
msgid "Add participant"
msgstr "添加参与人"
@@ -485,11 +474,11 @@ msgstr "历史设置改变"
#, python-format
msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s"
-msgstr ""
+msgstr "账单 %(name)s: %(property_name)s 从 %(before)s 改为 %(after)s"
#, python-format
msgid "Bill %(name)s: %(property_name)s changed to %(after)s"
-msgstr ""
+msgstr "账单 %(name)s: %(property_name)s 改为 %(after)s"
msgid "Confirm Remove IP Adresses"
msgstr "确认移除IP地址"
@@ -503,7 +492,6 @@ msgstr ""
"你确定要删除此项目里所有的IP地址吗?\n"
"项目其他内容不受影响,此操作不可撤回。"
-#, fuzzy
msgid "Confirm deletion"
msgstr "确认删除"
@@ -520,11 +508,11 @@ msgstr "确定删除此项目所有记录?此操作不可撤回。"
#, python-format
msgid "Bill %(name)s: added %(owers_list_str)s to owers list"
-msgstr ""
+msgstr "帐单 %(name)s:将 %(owers_list_str)s 添加到所有者列表"
#, python-format
msgid "Bill %(name)s: removed %(owers_list_str)s from owers list"
-msgstr ""
+msgstr "账单 %(name)s:从所有者列表中删除了 %(owers_list_str)s"
#, python-format
msgid ""
@@ -590,51 +578,51 @@ msgstr "IP地址记录可在设置里禁用"
msgid "From IP"
msgstr "从IP"
-#, fuzzy, python-format
+#, python-format
msgid "Project %(name)s added"
-msgstr "账目名称"
+msgstr "项目 %(name)s 已添加"
-#, fuzzy, python-format
+#, python-format
msgid "Bill %(name)s added"
-msgstr "帐单已添加"
+msgstr "帐单 %(name)s 已添加"
#, python-format
msgid "Participant %(name)s added"
-msgstr ""
+msgstr "成员 %(name)s 已添加"
msgid "Project private code changed"
msgstr "项目专用码已更改"
-#, fuzzy, python-format
+#, python-format
msgid "Project renamed to %(new_project_name)s"
-msgstr "项目的标识符是%(project)s"
+msgstr "项目的标识符是 %(new_project_name)s"
-#, fuzzy, python-format
+#, python-format
msgid "Project contact email changed to %(new_email)s"
-msgstr "项目联系邮箱更改为"
+msgstr "项目联系邮箱更改为 %(new_email)s"
msgid "Project settings modified"
msgstr "项目设置已修改"
#, python-format
msgid "Participant %(name)s deactivated"
-msgstr ""
+msgstr "成员 %(name)s 已停用"
#, python-format
msgid "Participant %(name)s reactivated"
-msgstr ""
+msgstr "成员 %(name)s 被重新激活"
#, python-format
msgid "Participant %(name)s renamed to %(new_name)s"
-msgstr ""
+msgstr "成员 %(name)s 重命名为 %(new_name)s"
#, python-format
msgid "Bill %(name)s renamed to %(new_description)s"
-msgstr ""
+msgstr "账单 %(name)s 更名为 %(new_description)s"
#, python-format
msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s"
-msgstr ""
+msgstr "成员 %(name)s:权重从%(old_weight)s变为%(new_weight)s"
msgid "Amount"
msgstr "数量"
@@ -643,33 +631,33 @@ msgstr "数量"
msgid "Amount in %(currency)s"
msgstr "%(currency)s的数量是"
-#, fuzzy, python-format
+#, python-format
msgid "Bill %(name)s modified"
-msgstr "帐单已修改"
+msgstr "帐单 %(name)s 已修改"
#, python-format
msgid "Participant %(name)s modified"
-msgstr ""
+msgstr "成员 %(name)s 已修改"
-#, fuzzy, python-format
+#, python-format
msgid "Bill %(name)s removed"
-msgstr "用户 '%(name)s'已被移除"
+msgstr "账单 %(name)s 已被移除"
-#, fuzzy, python-format
+#, python-format
msgid "Participant %(name)s removed"
-msgstr "用户 '%(name)s'已被移除"
+msgstr "用户 %(name)s 已被移除"
-#, fuzzy, python-format
+#, python-format
msgid "Project %(name)s changed in an unknown way"
-msgstr "未知的改变"
+msgstr "项目 %(name)s 以未知方式更改"
-#, fuzzy, python-format
+#, python-format
msgid "Bill %(name)s changed in an unknown way"
-msgstr "未知的改变"
+msgstr "账单 %(name)s 以一种未知的方式更改"
-#, fuzzy, python-format
+#, python-format
msgid "Participant %(name)s changed in an unknown way"
-msgstr "未知的改变"
+msgstr "成员 %(name)s 以未知方式更改"
msgid "Nothing to list"
msgstr "无列表"
@@ -815,7 +803,7 @@ msgid "No bills"
msgstr "没有账单"
msgid "Nothing to list yet."
-msgstr "没有列表"
+msgstr "没有列表。"
msgid "You probably want to"
msgstr "你想要"
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index 7370ac25..d54cea57 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -200,15 +200,21 @@ def admin():
def authenticate(project_id=None):
"""Authentication form"""
form = AuthenticationForm()
+
+ if not form.id.data and request.args.get("project_id"):
+ form.id.data = request.args["project_id"]
+ project_id = form.id.data
# Try to get project_id from token first
token = request.args.get("token")
if token:
- project_id = Project.verify_token(token, token_type="non_timed_token")
- token_auth = True
+ verified_project_id = Project.verify_token(
+ token, token_type="auth", project_id=project_id
+ )
+ if verified_project_id == project_id:
+ token_auth = True
+ else:
+ project_id = None
else:
- if not form.id.data and request.args.get("project_id"):
- form.id.data = request.args["project_id"]
- project_id = form.id.data
token_auth = False
if project_id is None:
# User doesn't provide project identifier or a valid token
@@ -389,7 +395,7 @@ def reset_password():
return render_template(
"reset_password.html", form=form, error=_("No token provided")
)
- project_id = Project.verify_token(token)
+ project_id = Project.verify_token(token, token_type="reset")
if not project_id:
return render_template(
"reset_password.html", form=form, error=_("Invalid token")
diff --git a/setup.cfg b/setup.cfg
index 3bbf6f27..f58c1619 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -33,6 +33,7 @@ install_requires =
Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b)
Flask-RESTful>=0.3.9,<1
Flask-SQLAlchemy>=2.4,<3
+ Flask-Talisman>=0.8,<1
Flask-WTF>=0.14.3,<1
WTForms>=2.3.1,<2.4
Flask>=2,<3