mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-29 09:52:36 +02:00
Use hashed passwords for projects (#286)
- Remove all occurences of clear text project passwords. - Migrate the database to hash the previously stored passwords. Closes #232
This commit is contained in:
parent
0dfb9c5f94
commit
c6f72e112b
8 changed files with 75 additions and 31 deletions
|
@ -21,6 +21,7 @@ Changed
|
||||||
- Use token based auth to reset passwords (#269)
|
- Use token based auth to reset passwords (#269)
|
||||||
- Better install doc (#275)
|
- Better install doc (#275)
|
||||||
- Use token based auth in invitation e-mails (#280)
|
- Use token based auth in invitation e-mails (#280)
|
||||||
|
- Use hashed passwords for projects (#286)
|
||||||
|
|
||||||
Added
|
Added
|
||||||
=====
|
=====
|
||||||
|
|
|
@ -5,6 +5,7 @@ from flask_rest import RESTResource, need_auth
|
||||||
from ihatemoney.models import db, Project, Person, Bill
|
from ihatemoney.models import db, Project, Person, Bill
|
||||||
from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
|
from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
|
||||||
get_billform_for)
|
get_billform_for)
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
|
|
||||||
api = Blueprint("api", __name__, url_prefix="/api")
|
api = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
@ -21,7 +22,7 @@ def check_project(*args, **kwargs):
|
||||||
if auth and "project_id" in kwargs and \
|
if auth and "project_id" in kwargs and \
|
||||||
auth.username == kwargs["project_id"]:
|
auth.username == kwargs["project_id"]:
|
||||||
project = Project.query.get(auth.username)
|
project = Project.query.get(auth.username)
|
||||||
if project and project.password == auth.password:
|
if project and check_password_hash(project.password, auth.password):
|
||||||
return project
|
return project
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ from wtforms.fields.simple import PasswordField, SubmitField, TextAreaField, Str
|
||||||
from wtforms.validators import Email, Required, ValidationError, EqualTo
|
from wtforms.validators import Email, Required, ValidationError, EqualTo
|
||||||
from flask_babel import lazy_gettext as _
|
from flask_babel import lazy_gettext as _
|
||||||
from flask import request
|
from flask import request
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from jinja2 import Markup
|
from jinja2 import Markup
|
||||||
|
@ -52,14 +53,14 @@ class EditProjectForm(FlaskForm):
|
||||||
Returns the created instance
|
Returns the created instance
|
||||||
"""
|
"""
|
||||||
project = Project(name=self.name.data, id=self.id.data,
|
project = Project(name=self.name.data, id=self.id.data,
|
||||||
password=self.password.data,
|
password=generate_password_hash(self.password.data),
|
||||||
contact_email=self.contact_email.data)
|
contact_email=self.contact_email.data)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
def update(self, project):
|
def update(self, project):
|
||||||
"""Update the project with the information from the form"""
|
"""Update the project with the information from the form"""
|
||||||
project.name = self.name.data
|
project.name = self.name.data
|
||||||
project.password = self.password.data
|
project.password = generate_password_hash(self.password.data)
|
||||||
project.contact_email = self.contact_email.data
|
project.contact_email = self.contact_email.data
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""hash project passwords
|
||||||
|
|
||||||
|
Revision ID: b78f8a8bdb16
|
||||||
|
Revises: f629c8ef4ab0
|
||||||
|
Create Date: 2017-12-17 11:45:44.783238
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b78f8a8bdb16'
|
||||||
|
down_revision = 'f629c8ef4ab0'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
project_helper = sa.Table(
|
||||||
|
'project', sa.MetaData(),
|
||||||
|
sa.Column('id', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('name', sa.UnicodeText(), nullable=True),
|
||||||
|
sa.Column('password', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('contact_email', sa.String(length=128), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
for project in connection.execute(project_helper.select()):
|
||||||
|
connection.execute(
|
||||||
|
project_helper.update().where(
|
||||||
|
project_helper.c.name == project.name
|
||||||
|
).values(
|
||||||
|
password=generate_password_hash(project.password)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Downgrade path is not possible, because information has been lost.
|
||||||
|
pass
|
|
@ -2,8 +2,7 @@ Hi,
|
||||||
|
|
||||||
You have just (or someone else using your email address) created the project "{{ g.project.name }}" to share your expenses.
|
You have just (or someone else using your email address) created the project "{{ g.project.name }}" to share your expenses.
|
||||||
|
|
||||||
You can access it here: {{ url_for(".list_bills", _external=True) }} (the identifier is {{ g.project.id }}),
|
You can access it here: {{ url_for(".list_bills", _external=True) }} (the identifier is {{ g.project.id }}).
|
||||||
and the shared password is "{{ g.project.password }}".
|
|
||||||
If you want to share this project with your friends, you can share the identifier and the shared password with them or send them invitations with the following link:
|
If you want to share this project with your friends, you can share the identifier and the shared password with them or send them invitations with the following link:
|
||||||
{{ url_for(".invite", _external=True) }}
|
{{ url_for(".invite", _external=True) }}
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,7 @@ Hey,
|
||||||
|
|
||||||
Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépenses.
|
Vous venez de créer le projet "{{ g.project.name }}" pour partager vos dépenses.
|
||||||
|
|
||||||
Vous pouvez y accéder ici: {{ url_for(".list_bills", _external=True) }} (l'identifieur est {{ g.project.id }}),
|
Vous pouvez y accéder ici: {{ url_for(".list_bills", _external=True) }} (l'identifieur est {{ g.project.id }}).
|
||||||
et le code d'accès "{{ g.project.password }}".
|
|
||||||
Si vous voulez partager ce projet avec vos amis, vous pouvez partager son identifiant et son code d'accès avec eux ou leur envoyer une invitation avec le lien suivant :
|
Si vous voulez partager ce projet avec vos amis, vous pouvez partager son identifiant et son code d'accès avec eux ou leur envoyer une invitation avec le lien suivant :
|
||||||
{{ url_for(".invite", _external=True) }}
|
{{ url_for(".invite", _external=True) }}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ from collections import defaultdict
|
||||||
import six
|
import six
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask import session
|
from flask import session
|
||||||
from flask_testing import TestCase
|
from flask_testing import TestCase
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ class BaseTestCase(TestCase):
|
||||||
project = models.Project(
|
project = models.Project(
|
||||||
id=name,
|
id=name,
|
||||||
name=six.text_type(name),
|
name=six.text_type(name),
|
||||||
password=name,
|
password=generate_password_hash(name),
|
||||||
contact_email="%s@notmyidea.org" % name)
|
contact_email="%s@notmyidea.org" % name)
|
||||||
models.db.session.add(project)
|
models.db.session.add(project)
|
||||||
models.db.session.commit()
|
models.db.session.commit()
|
||||||
|
@ -670,8 +670,9 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
project = models.Project.query.get("raclette")
|
project = models.Project.query.get("raclette")
|
||||||
|
|
||||||
for key, value in new_data.items():
|
self.assertEqual(project.name, new_data['name'])
|
||||||
self.assertEqual(getattr(project, key), value, key)
|
self.assertEqual(project.contact_email, new_data['contact_email'])
|
||||||
|
self.assertTrue(check_password_hash(project.password, new_data['password']))
|
||||||
|
|
||||||
# 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'
|
||||||
|
@ -1071,11 +1072,12 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"name": "raclette",
|
"name": "raclette",
|
||||||
"contact_email": "raclette@notmyidea.org",
|
"contact_email": "raclette@notmyidea.org",
|
||||||
"members": [],
|
"members": [],
|
||||||
"password": "raclette",
|
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"balance": {},
|
"balance": {},
|
||||||
}
|
}
|
||||||
self.assertDictEqual(json.loads(resp.data.decode('utf-8')), expected)
|
decoded_resp = json.loads(resp.data.decode('utf-8'))
|
||||||
|
self.assertTrue(check_password_hash(decoded_resp.pop('password'), 'raclette'))
|
||||||
|
self.assertDictEqual(decoded_resp, expected)
|
||||||
|
|
||||||
# edit should work
|
# edit should work
|
||||||
resp = self.client.put("/api/projects/raclette", data={
|
resp = self.client.put("/api/projects/raclette", data={
|
||||||
|
@ -1095,11 +1097,12 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
"name": "The raclette party",
|
"name": "The raclette party",
|
||||||
"contact_email": "yeah@notmyidea.org",
|
"contact_email": "yeah@notmyidea.org",
|
||||||
"members": [],
|
"members": [],
|
||||||
"password": "raclette",
|
|
||||||
"id": "raclette",
|
"id": "raclette",
|
||||||
"balance": {},
|
"balance": {},
|
||||||
}
|
}
|
||||||
self.assertDictEqual(json.loads(resp.data.decode('utf-8')), expected)
|
decoded_resp = json.loads(resp.data.decode('utf-8'))
|
||||||
|
self.assertTrue(check_password_hash(decoded_resp.pop('password'), 'raclette'))
|
||||||
|
self.assertDictEqual(decoded_resp, expected)
|
||||||
|
|
||||||
# delete should work
|
# delete should work
|
||||||
resp = self.client.delete("/api/projects/raclette",
|
resp = self.client.delete("/api/projects/raclette",
|
||||||
|
@ -1334,11 +1337,12 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
{"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0},
|
{"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0},
|
||||||
{"activated": True, "id": 3, "name": "arnaud", "weight": 1.0}
|
{"activated": True, "id": 3, "name": "arnaud", "weight": 1.0}
|
||||||
],
|
],
|
||||||
"name": "raclette",
|
"name": "raclette"}
|
||||||
"password": "raclette"}
|
|
||||||
|
|
||||||
self.assertStatus(200, req)
|
self.assertStatus(200, req)
|
||||||
self.assertEqual(expected, json.loads(req.data.decode('utf-8')))
|
decoded_req = json.loads(req.data.decode('utf-8'))
|
||||||
|
self.assertTrue(check_password_hash(decoded_req.pop('password'), 'raclette'))
|
||||||
|
self.assertDictEqual(decoded_req, expected)
|
||||||
|
|
||||||
|
|
||||||
class ServerTestCase(APITestCase):
|
class ServerTestCase(APITestCase):
|
||||||
|
|
|
@ -15,7 +15,7 @@ from flask import (
|
||||||
)
|
)
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from flask_babel import get_locale, gettext as _
|
from flask_babel import get_locale, gettext as _
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
from smtplib import SMTPRecipientsRefused
|
from smtplib import SMTPRecipientsRefused
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
@ -181,8 +181,7 @@ def authenticate(project_id=None):
|
||||||
|
|
||||||
# else do form authentication or token authentication
|
# else do form authentication or token authentication
|
||||||
is_post_auth = request.method == "POST" and form.validate()
|
is_post_auth = request.method == "POST" and form.validate()
|
||||||
is_valid_password = form.password.data == project.password
|
if is_post_auth and check_password_hash(project.password, form.password.data) or token_auth:
|
||||||
if is_post_auth and is_valid_password or token_auth:
|
|
||||||
# maintain a list of visited projects
|
# maintain a list of visited projects
|
||||||
if "projects" not in session:
|
if "projects" not in session:
|
||||||
session["projects"] = []
|
session["projects"] = []
|
||||||
|
@ -192,7 +191,7 @@ def authenticate(project_id=None):
|
||||||
session.update()
|
session.update()
|
||||||
setattr(g, 'project', project)
|
setattr(g, 'project', project)
|
||||||
return redirect(url_for(".list_bills"))
|
return redirect(url_for(".list_bills"))
|
||||||
if is_post_auth and not is_valid_password:
|
if is_post_auth and not check_password_hash(project.password, form.password.data):
|
||||||
msg = _("This private code is not the right one")
|
msg = _("This private code is not the right one")
|
||||||
form.errors['password'] = [msg]
|
form.errors['password'] = [msg]
|
||||||
|
|
||||||
|
@ -297,9 +296,8 @@ def reset_password():
|
||||||
if not project:
|
if not project:
|
||||||
return render_template('reset_password.html', form=form, error=_("Unknown project"))
|
return render_template('reset_password.html', form=form, error=_("Unknown project"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST" and form.validate():
|
||||||
if form.validate():
|
project.password = generate_password_hash(form.password.data)
|
||||||
project.password = form.password.data
|
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("Password successfully reset."))
|
flash(_("Password successfully reset."))
|
||||||
|
@ -342,7 +340,6 @@ def edit_project():
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
edit_form.name.data = g.project.name
|
edit_form.name.data = g.project.name
|
||||||
edit_form.password.data = g.project.password
|
|
||||||
edit_form.contact_email.data = g.project.contact_email
|
edit_form.contact_email.data = g.project.contact_email
|
||||||
|
|
||||||
return render_template("edit_project.html", edit_form=edit_form, export_form=export_form)
|
return render_template("edit_project.html", edit_form=edit_form, export_form=export_form)
|
||||||
|
@ -379,7 +376,8 @@ def demo():
|
||||||
raise Redirect303(url_for(".create_project",
|
raise Redirect303(url_for(".create_project",
|
||||||
project_id='demo'))
|
project_id='demo'))
|
||||||
if not project and is_demo_project_activated:
|
if not project and is_demo_project_activated:
|
||||||
project = Project(id="demo", name=u"demonstration", password="demo",
|
project = Project(id="demo", name=u"demonstration",
|
||||||
|
password=generate_password_hash("demo"),
|
||||||
contact_email="demo@notmyidea.org")
|
contact_email="demo@notmyidea.org")
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
Loading…
Reference in a new issue