mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
Require private code to edit a project settings
This is something we had documented in our security documentation [1], but we didn't actually do it... As mentioned in [1], this has good security properties: you can invite somebody with an invitation link, and they will be able to access the project but not change the private code (because they don't know the current private code). This new check also applies to all other settings (email address, history settings, currency), which is desirable. Only somebody with knowledge of the private code can now change these settings. [1] https://ihatemoney.readthedocs.io/en/latest/security.html#giving-access-to-a-project
This commit is contained in:
parent
b1d4f34193
commit
68e1dac75c
5 changed files with 101 additions and 16 deletions
|
@ -121,6 +121,11 @@ class CalculatorStringField(StringField):
|
||||||
|
|
||||||
class EditProjectForm(FlaskForm):
|
class EditProjectForm(FlaskForm):
|
||||||
name = StringField(_("Project name"), validators=[DataRequired()])
|
name = StringField(_("Project name"), validators=[DataRequired()])
|
||||||
|
current_password = PasswordField(
|
||||||
|
_("Current private code"),
|
||||||
|
description=_("Enter existing private code to edit project"),
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
# If empty -> don't change the password
|
# If empty -> don't change the password
|
||||||
password = PasswordField(
|
password = PasswordField(
|
||||||
_("New private code"),
|
_("New private code"),
|
||||||
|
@ -154,6 +159,13 @@ class EditProjectForm(FlaskForm):
|
||||||
for currency_name in self.currency_helper.get_currencies()
|
for currency_name in self.currency_helper.get_currencies()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate_current_password(self, field):
|
||||||
|
project = Project.query.get(self.id.data)
|
||||||
|
if project is None:
|
||||||
|
raise ValidationError(_("Unknown error"))
|
||||||
|
if not check_password_hash(project.password, self.current_password.data):
|
||||||
|
raise ValidationError(_("Invalid private code."))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logging_preference(self):
|
def logging_preference(self):
|
||||||
"""Get the LoggingMode object corresponding to current form data."""
|
"""Get the LoggingMode object corresponding to current form data."""
|
||||||
|
@ -212,7 +224,9 @@ class ImportProjectForm(FlaskForm):
|
||||||
|
|
||||||
class ProjectForm(EditProjectForm):
|
class ProjectForm(EditProjectForm):
|
||||||
id = StringField(_("Project identifier"), validators=[DataRequired()])
|
id = StringField(_("Project identifier"), validators=[DataRequired()])
|
||||||
# This field overrides the one from EditProjectForm
|
# Remove this field that is inherited from EditProjectForm
|
||||||
|
current_password = None
|
||||||
|
# This field overrides the one from EditProjectForm (to make it mandatory)
|
||||||
password = PasswordField(_("Private code"), validators=[DataRequired()])
|
password = PasswordField(_("Private code"), validators=[DataRequired()])
|
||||||
submit = SubmitField(_("Create the project"))
|
submit = SubmitField(_("Create the project"))
|
||||||
|
|
||||||
|
|
|
@ -100,6 +100,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ input(form.default_currency) }}
|
{{ input(form.default_currency) }}
|
||||||
|
{{ input(form.current_password) }}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary">{{ _("Save changes") }}</button>
|
<button class="btn btn-primary">{{ _("Save changes") }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -131,7 +131,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
decoded_resp = json.loads(resp.data.decode("utf-8"))
|
decoded_resp = json.loads(resp.data.decode("utf-8"))
|
||||||
self.assertDictEqual(decoded_resp, expected)
|
self.assertDictEqual(decoded_resp, expected)
|
||||||
|
|
||||||
# edit should work
|
# edit should fail if we don't provide the current private code
|
||||||
resp = self.client.put(
|
resp = self.client.put(
|
||||||
"/api/projects/raclette",
|
"/api/projects/raclette",
|
||||||
data={
|
data={
|
||||||
|
@ -143,7 +143,36 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
},
|
},
|
||||||
headers=self.get_auth("raclette"),
|
headers=self.get_auth("raclette"),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(400, resp.status_code)
|
||||||
|
|
||||||
|
# edit should fail if we provide the wrong private code
|
||||||
|
resp = self.client.put(
|
||||||
|
"/api/projects/raclette",
|
||||||
|
data={
|
||||||
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "XXX",
|
||||||
|
"current_password": "fromage aux patates",
|
||||||
|
"password": "raclette",
|
||||||
|
"name": "The raclette party",
|
||||||
|
"project_history": "y",
|
||||||
|
},
|
||||||
|
headers=self.get_auth("raclette"),
|
||||||
|
)
|
||||||
|
self.assertEqual(400, resp.status_code)
|
||||||
|
|
||||||
|
# edit with the correct private code should work
|
||||||
|
resp = self.client.put(
|
||||||
|
"/api/projects/raclette",
|
||||||
|
data={
|
||||||
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "XXX",
|
||||||
|
"current_password": "raclette",
|
||||||
|
"password": "raclette",
|
||||||
|
"name": "The raclette party",
|
||||||
|
"project_history": "y",
|
||||||
|
},
|
||||||
|
headers=self.get_auth("raclette"),
|
||||||
|
)
|
||||||
self.assertEqual(200, resp.status_code)
|
self.assertEqual(200, resp.status_code)
|
||||||
|
|
||||||
resp = self.client.get(
|
resp = self.client.get(
|
||||||
|
@ -168,6 +197,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
data={
|
data={
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
"default_currency": "XXX",
|
"default_currency": "XXX",
|
||||||
|
"current_password": "raclette",
|
||||||
"password": "tartiflette",
|
"password": "tartiflette",
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
},
|
},
|
||||||
|
@ -213,9 +243,23 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"/api/projects/raclette/token",
|
"/api/projects/raclette/token",
|
||||||
headers={"Authorization": f"Basic {decoded_resp['token']}"},
|
headers={"Authorization": f"Basic {decoded_resp['token']}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(200, resp.status_code)
|
self.assertEqual(200, resp.status_code)
|
||||||
|
|
||||||
|
# We shouldn't be able to edit project without private code
|
||||||
|
resp = self.client.put(
|
||||||
|
"/api/projects/raclette",
|
||||||
|
data={
|
||||||
|
"contact_email": "yeah@notmyidea.org",
|
||||||
|
"default_currency": "XXX",
|
||||||
|
"password": "tartiflette",
|
||||||
|
"name": "The raclette party",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Basic {decoded_resp['token']}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(400, resp.status_code)
|
||||||
|
expected_resp = {"current_password": ["This field is required."]}
|
||||||
|
self.assertEqual(expected_resp, json.loads(resp.data.decode("utf-8")))
|
||||||
|
|
||||||
def test_token_login(self):
|
def test_token_login(self):
|
||||||
resp = self.api_create("raclette")
|
resp = self.api_create("raclette")
|
||||||
# Get token
|
# Get token
|
||||||
|
@ -719,6 +763,7 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
data={
|
data={
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
"default_currency": "XXX",
|
"default_currency": "XXX",
|
||||||
|
"current_password": "raclette",
|
||||||
"password": "raclette",
|
"password": "raclette",
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
},
|
},
|
||||||
|
|
|
@ -181,6 +181,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
data={
|
data={
|
||||||
"name": "raclette",
|
"name": "raclette",
|
||||||
"contact_email": "zorglub@notmyidea.org",
|
"contact_email": "zorglub@notmyidea.org",
|
||||||
|
"current_password": "raclette",
|
||||||
"password": "didoudida",
|
"password": "didoudida",
|
||||||
"default_currency": "XXX",
|
"default_currency": "XXX",
|
||||||
},
|
},
|
||||||
|
@ -922,10 +923,30 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
"default_currency": "USD",
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
|
# It should fail if we don't provide the current password
|
||||||
self.assertEqual(resp.status_code, 200)
|
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False)
|
||||||
|
self.assertIn("This field is required", resp.data.decode("utf-8"))
|
||||||
project = self.get_project("raclette")
|
project = self.get_project("raclette")
|
||||||
|
self.assertNotEqual(project.name, new_data["name"])
|
||||||
|
self.assertNotEqual(project.contact_email, new_data["contact_email"])
|
||||||
|
self.assertNotEqual(project.default_currency, new_data["default_currency"])
|
||||||
|
self.assertFalse(check_password_hash(project.password, new_data["password"]))
|
||||||
|
|
||||||
|
# It should fail if we provide the wrong current password
|
||||||
|
new_data["current_password"] = "patates au fromage"
|
||||||
|
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False)
|
||||||
|
self.assertIn("Invalid private code", resp.data.decode("utf-8"))
|
||||||
|
project = self.get_project("raclette")
|
||||||
|
self.assertNotEqual(project.name, new_data["name"])
|
||||||
|
self.assertNotEqual(project.contact_email, new_data["contact_email"])
|
||||||
|
self.assertNotEqual(project.default_currency, new_data["default_currency"])
|
||||||
|
self.assertFalse(check_password_hash(project.password, new_data["password"]))
|
||||||
|
|
||||||
|
# It should work if we give the current private code
|
||||||
|
new_data["current_password"] = "raclette"
|
||||||
|
resp = self.client.post("/raclette/edit", data=new_data)
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
project = self.get_project("raclette")
|
||||||
self.assertEqual(project.name, new_data["name"])
|
self.assertEqual(project.name, new_data["name"])
|
||||||
self.assertEqual(project.contact_email, new_data["contact_email"])
|
self.assertEqual(project.contact_email, new_data["contact_email"])
|
||||||
self.assertEqual(project.default_currency, new_data["default_currency"])
|
self.assertEqual(project.default_currency, new_data["default_currency"])
|
||||||
|
@ -934,7 +955,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
# Editing a project with a wrong email address should fail
|
# Editing a project with a wrong email address should fail
|
||||||
new_data["contact_email"] = "wrong_email"
|
new_data["contact_email"] = "wrong_email"
|
||||||
|
|
||||||
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
|
resp = self.client.post("/raclette/edit", data=new_data)
|
||||||
self.assertIn("Invalid email address", resp.data.decode("utf-8"))
|
self.assertIn("Invalid email address", resp.data.decode("utf-8"))
|
||||||
|
|
||||||
def test_dashboard(self):
|
def test_dashboard(self):
|
||||||
|
@ -2039,6 +2060,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
data={
|
data={
|
||||||
"name": "raclette",
|
"name": "raclette",
|
||||||
"contact_email": "zorglub@notmyidea.org",
|
"contact_email": "zorglub@notmyidea.org",
|
||||||
|
"current_password": "raclette",
|
||||||
"password": "didoudida",
|
"password": "didoudida",
|
||||||
"default_currency": "XXX",
|
"default_currency": "XXX",
|
||||||
},
|
},
|
||||||
|
|
|
@ -19,11 +19,12 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
||||||
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
||||||
|
|
||||||
def change_privacy_to(self, logging_preference):
|
def change_privacy_to(self, current_password, logging_preference):
|
||||||
# Change only logging_preferences
|
# Change only logging_preferences
|
||||||
new_data = {
|
new_data = {
|
||||||
"name": "demo",
|
"name": "demo",
|
||||||
"contact_email": "demo@notmyidea.org",
|
"contact_email": "demo@notmyidea.org",
|
||||||
|
"current_password": current_password,
|
||||||
"password": "demo",
|
"password": "demo",
|
||||||
"default_currency": "XXX",
|
"default_currency": "XXX",
|
||||||
}
|
}
|
||||||
|
@ -76,6 +77,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
new_data = {
|
new_data = {
|
||||||
"name": "demo2",
|
"name": "demo2",
|
||||||
"contact_email": "demo2@notmyidea.org",
|
"contact_email": "demo2@notmyidea.org",
|
||||||
|
"current_password": "demo",
|
||||||
"password": "123456",
|
"password": "123456",
|
||||||
"project_history": "y",
|
"project_history": "y",
|
||||||
"default_currency": "USD", # Currency changed from default
|
"default_currency": "USD", # Currency changed from default
|
||||||
|
@ -114,7 +116,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
resp.data.decode("utf-8"),
|
resp.data.decode("utf-8"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.change_privacy_to(LoggingMode.DISABLED)
|
self.change_privacy_to("demo", LoggingMode.DISABLED)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -122,7 +124,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
||||||
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
||||||
|
|
||||||
self.change_privacy_to(LoggingMode.RECORD_IP)
|
self.change_privacy_to("demo", LoggingMode.RECORD_IP)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -132,7 +134,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
||||||
|
|
||||||
self.change_privacy_to(LoggingMode.ENABLED)
|
self.change_privacy_to("demo", LoggingMode.ENABLED)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -141,7 +143,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
||||||
|
|
||||||
def test_project_privacy_edit2(self):
|
def test_project_privacy_edit2(self):
|
||||||
self.change_privacy_to(LoggingMode.RECORD_IP)
|
self.change_privacy_to("demo", LoggingMode.RECORD_IP)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -149,7 +151,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
||||||
|
|
||||||
self.change_privacy_to(LoggingMode.DISABLED)
|
self.change_privacy_to("demo", LoggingMode.DISABLED)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -159,7 +161,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
||||||
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
||||||
|
|
||||||
self.change_privacy_to(LoggingMode.ENABLED)
|
self.change_privacy_to("demo", LoggingMode.ENABLED)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
@ -171,6 +173,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
new_data = {
|
new_data = {
|
||||||
"name": "demo2",
|
"name": "demo2",
|
||||||
"contact_email": "demo2@notmyidea.org",
|
"contact_email": "demo2@notmyidea.org",
|
||||||
|
"current_password": "demo",
|
||||||
"password": "123456",
|
"password": "123456",
|
||||||
"default_currency": "USD",
|
"default_currency": "USD",
|
||||||
}
|
}
|
||||||
|
@ -233,7 +236,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
|
|
||||||
def test_disable_clear_no_new_records(self):
|
def test_disable_clear_no_new_records(self):
|
||||||
# Disable logging
|
# Disable logging
|
||||||
self.change_privacy_to(LoggingMode.DISABLED)
|
self.change_privacy_to("demo", LoggingMode.DISABLED)
|
||||||
|
|
||||||
# Ensure we can't clear history with a GET or with a password-less POST
|
# Ensure we can't clear history with a GET or with a password-less POST
|
||||||
resp = self.client.get("/demo/erase_history")
|
resp = self.client.get("/demo/erase_history")
|
||||||
|
@ -276,13 +279,13 @@ class HistoryTestCase(IhatemoneyTestCase):
|
||||||
|
|
||||||
def test_clear_ip_records(self):
|
def test_clear_ip_records(self):
|
||||||
# Enable IP Recording
|
# Enable IP Recording
|
||||||
self.change_privacy_to(LoggingMode.RECORD_IP)
|
self.change_privacy_to("demo", LoggingMode.RECORD_IP)
|
||||||
|
|
||||||
# Do lots of database operations to generate IP address entries
|
# Do lots of database operations to generate IP address entries
|
||||||
self.do_misc_database_operations(LoggingMode.RECORD_IP)
|
self.do_misc_database_operations(LoggingMode.RECORD_IP)
|
||||||
|
|
||||||
# Disable IP Recording
|
# Disable IP Recording
|
||||||
self.change_privacy_to(LoggingMode.ENABLED)
|
self.change_privacy_to("123456", LoggingMode.ENABLED)
|
||||||
|
|
||||||
resp = self.client.get("/demo/history")
|
resp = self.client.get("/demo/history")
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
Loading…
Reference in a new issue