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