Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Require private code to edit a project settings, document better the invite options #1204

Merged
merged 5 commits into from
Jul 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ the token (of course, you need to authenticate):
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/token
{"token": "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q"}

Make sure to store this token securely: it allows full access to the
Make sure to store this token securely: it allows almost full access to the
project. For instance, use it to obtain information about the project
(replace PROJECT_TOKEN with the actual token):
(replace `PROJECT_TOKEN` with the actual token):

$ curl --oauth2-bearer "PROJECT_TOKEN" https://ihatemoney.org/api/projects/demo

Expand All @@ -51,7 +51,8 @@ simply create an URL of the form:

https://ihatemoney.org/demo/join/PROJECT_TOKEN

Such a link grants full access to the project associated with the token.
Such a link grants read-write access to the project associated with the token,
but it does not allow to change project settings.

### Projects

Expand All @@ -67,8 +68,8 @@ A project needs the following arguments:
- `name`: the project name (string)
- `id`: the project identifier (string without special chars or
spaces)
- `password`: the project password / secret code (string)
- `contact_email`: the contact email (string)
- `password`: the project password / private code (string)
- `contact_email`: the contact email, used to recover the private code (string)

Optional arguments:

Expand All @@ -83,7 +84,9 @@ Here is the command:
-d 'name=yay&id=yay&password=yay&[email protected]'
"yay"

As you can see, the API returns the identifier of the project.
As you can see, the API returns the identifier of the project. It might be different
from what you requested, because the ID is normalized (remove special characters,
change to lowercase, etc).

#### Getting information about the project

Expand All @@ -108,7 +111,12 @@ Updating a project is done with the `PUT` verb:

$ curl --basic -u yay:yay -X PUT\
https://ihatemoney.org/api/projects/yay -d\
'name=yay&id=yay&password=yay&[email protected]'
'name=yay&id=yay&current_password=yay&password=newyay&[email protected]'

You need to give the current private code as the `current_password` field. This is a security
measure to ensure that knowledge of an auth token is not enough to update settings.

Note that in any case you can never change the ID of a project.

#### Deleting a project

Expand Down
26 changes: 17 additions & 9 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,25 @@ A project has four main parameters when it comes to security:
Somebody with the **private code** can:

- access the project through the web interface or the API
- add, modify or remove participants
- add, modify or remove bills
- view statistics of the project
- view project history
- change basic settings of the project
- change the email address associated to the project
- change the private code of the project
- delete the project

Somebody with the **auth token** can manipulate the project through the API to do
essentially the same thing:
Somebody with the **auth token** can manipulate the project through the API:

- access the project
- add, modify or remove participants
- add, modify or remove bills
- change basic settings of the project
- change the email address associated to the project
- change the private code of the project
- view statistics of the project
- delete the project

The auth token is not enough to change basic settings of the project,
or to change the email address or the private code.

The auth token can also be used to build "invitation links". These links
allow to login on the web interface without knowing the private code,
Expand All @@ -61,9 +66,12 @@ The second method is interesting because it does not reveal the private
code. In particular, somebody that is logged-in through the invitation
link will not be able to change the private code, because the web
interface requires a confirmation of the existing private code to change
it. However, a motivated person could extract the auth token from the
it. Similarly, changing other important settings or deleting the project
from the web interface requires knowledge of the private code.

However, a motivated person could extract the auth token from the
invitation link, use it to access the project through the API, and
change the private code through the API.
delete the project through the API. This is a [known issue](https://github.com/spiral-project/ihatemoney/issues/1206).

## Removing access to a project

Expand Down Expand Up @@ -103,6 +111,6 @@ Note, however, that the history feature is primarily meant to protect
against mistakes: a malicious member can easily remove all entries from
the history!

The best defense against this kind of issues is\... backups! All data
The best defense against this kind of issues is... backups! All data
for a project can be exported through the settings page or through the
API.
API. The server administrator can also backup the database.
16 changes: 15 additions & 1 deletion ihatemoney/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ def process_formdata(self, valuelist):

class EditProjectForm(FlaskForm):
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
password = PasswordField(
_("New private code"),
Expand Down Expand Up @@ -154,6 +159,13 @@ def __init__(self, *args, **kwargs):
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
def logging_preference(self):
"""Get the LoggingMode object corresponding to current form data."""
Expand Down Expand Up @@ -212,7 +224,9 @@ class ImportProjectForm(FlaskForm):

class ProjectForm(EditProjectForm):
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()])
submit = SubmitField(_("Create the project"))

Expand Down
1 change: 1 addition & 0 deletions ihatemoney/templates/forms.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
</div>

{{ input(form.default_currency) }}
{{ input(form.current_password) }}
<div class="actions">
<button class="btn btn-primary">{{ _("Save changes") }}</button>
</div>
Expand Down
29 changes: 16 additions & 13 deletions ihatemoney/templates/send_invites.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,10 @@ <h1>{{ _("Invite people to join this project") }}</h1>
<tbody>
<tr>
<td>
<h3>{{ _('Share Identifier & code') }}</h3>
</td>
<td>
{{ _("You can share the project identifier and the private code by any communication means.") }}
<br />
<strong>{{ _('Identifier:') }}</strong> <a href="{{ url_for("main.list_bills", project_id=g.project.id) }}">{{ g.project.id }}</a>
</td>
</tr>
<tr>
<td>
<h3>{{ _('Share the Link') }}</h3>
<h3>{{ _('Share an invitation link') }}</h3>
</td>
<td>
{{ _("You can directly share the following link via your prefered medium") }}</br>
{{ _("The easiest way to invite people is to give them the following invitation link.<br />They will be able to access the project, manage participants, add/edit/delete bills. However, they will not have access to important settings such as changing the private code or deleting the whole project.") }}</br>
<a href="{{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}">
{{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}
</a>
Expand All @@ -41,13 +31,26 @@ <h3>{{ _('Send via Emails') }}</h3>
</td>
<td>
<p>{{ _("Specify a (comma separated) list of email adresses you want to notify about the
creation of this budget management project and we will send them an email for you.") }}</p>
creation of this budget management project and we will send them an email with the invitation link.") }}</p>
{% include "display_errors.html" %}
<form class="invites form-horizontal" method="post" accept-charset="utf-8">
{{ forms.invites(form) }}
</form>
</td>
</tr>
<tr>
<td>
<h3>{{ _('Share Identifier & code') }}</h3>
</td>
<td>
<p>{{ _("You can share the project identifier and the private code by any communication means.<br />Anyone with the private code will have access to the full project, including changing settings such as the private code or project email address, or even deleting the whole project.") }}</p>
<p>
<strong>{{ _('Identifier:') }}</strong> <a href="{{ url_for("main.list_bills", project_id=g.project.id) }}">{{ g.project.id }}</a>
<br />
<strong>{{ _('Private code:') }}</strong> {{ _('the private code was defined when you created the project') }}
</p>
</td>
</tr>
</tbody>
</table>

Expand Down
49 changes: 47 additions & 2 deletions ihatemoney/tests/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test_project(self):
decoded_resp = json.loads(resp.data.decode("utf-8"))
self.assertDictEqual(decoded_resp, expected)

# edit should work
# edit should fail if we don't provide the current private code
resp = self.client.put(
"/api/projects/raclette",
data={
Expand All @@ -143,7 +143,36 @@ def test_project(self):
},
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": "[email protected]",
"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": "[email protected]",
"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)

resp = self.client.get(
Expand All @@ -168,6 +197,7 @@ def test_project(self):
data={
"contact_email": "[email protected]",
"default_currency": "XXX",
"current_password": "raclette",
"password": "tartiflette",
"name": "The raclette party",
},
Expand Down Expand Up @@ -213,9 +243,23 @@ def test_token_creation(self):
"/api/projects/raclette/token",
headers={"Authorization": f"Basic {decoded_resp['token']}"},
)

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": "[email protected]",
"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):
resp = self.api_create("raclette")
# Get token
Expand Down Expand Up @@ -719,6 +763,7 @@ def test_currencies(self):
data={
"contact_email": "[email protected]",
"default_currency": "XXX",
"current_password": "raclette",
"password": "raclette",
"name": "The raclette party",
},
Expand Down
32 changes: 27 additions & 5 deletions ihatemoney/tests/budget_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def test_invite_code_invalidation(self):
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")
link = extract_link(response, "give them the following invitation link")

self.client.post("/exit")
response = self.client.get(link)
Expand All @@ -181,6 +181,7 @@ def test_invite_code_invalidation(self):
data={
"name": "raclette",
"contact_email": "[email protected]",
"current_password": "raclette",
"password": "didoudida",
"default_currency": "XXX",
},
Expand Down Expand Up @@ -922,10 +923,30 @@ def test_edit_project(self):
"default_currency": "USD",
}

resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
self.assertEqual(resp.status_code, 200)
# It should fail if we don't provide the current password
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")
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.contact_email, new_data["contact_email"])
self.assertEqual(project.default_currency, new_data["default_currency"])
Expand All @@ -934,7 +955,7 @@ def test_edit_project(self):
# Editing a project with a wrong email address should fail
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"))

def test_dashboard(self):
Expand Down Expand Up @@ -2039,6 +2060,7 @@ def test_rss_feed_invalidated_token(self):
data={
"name": "raclette",
"contact_email": "[email protected]",
"current_password": "raclette",
"password": "didoudida",
"default_currency": "XXX",
},
Expand Down
Loading