feat: use sqlalchemy-utils PasswordType to store and hash user passwords

This commit is contained in:
Éloi Rivard 2023-11-29 09:19:20 +01:00
parent 9463d0c52a
commit a7e574f754
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
5 changed files with 73 additions and 13 deletions

View file

@ -12,6 +12,7 @@ Fixed
- Password reset and initialization mails were not sent at all the user - Password reset and initialization mails were not sent at all the user
addresses if one email address could not be reached. addresses if one email address could not be reached.
- Password comparision was too permissive on login. - Password comparision was too permissive on login.
- Encrypt passwords in the SQL backend.
[0.0.35] - 2023-11-25 [0.0.35] - 2023-11-25
===================== =====================

View file

@ -21,11 +21,15 @@ from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import reconstructor from sqlalchemy.orm import reconstructor
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_json import MutableJson from sqlalchemy_json import MutableJson
from sqlalchemy_utils import force_auto_coercion
from sqlalchemy_utils import PasswordType
from .backend import Backend from .backend import Backend
from .backend import Base from .backend import Base
from .utils import TZDateTime from .utils import TZDateTime
force_auto_coercion()
class SqlAlchemyModel(Model): class SqlAlchemyModel(Model):
def __html__(self): def __html__(self):
@ -120,7 +124,9 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
String, primary_key=True, default=lambda: str(uuid.uuid4()) String, primary_key=True, default=lambda: str(uuid.uuid4())
) )
user_name: Mapped[str] = mapped_column(String, unique=True, nullable=False) user_name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
password: Mapped[str] = mapped_column(String, nullable=True) password: Mapped[str] = mapped_column(
PasswordType(schemes=["pbkdf2_sha512"]), nullable=True
)
preferred_language: Mapped[str] = mapped_column(String, nullable=True) preferred_language: Mapped[str] = mapped_column(String, nullable=True)
family_name: Mapped[str] = mapped_column(String, nullable=True) family_name: Mapped[str] = mapped_column(String, nullable=True)
given_name: Mapped[str] = mapped_column(String, nullable=True) given_name: Mapped[str] = mapped_column(String, nullable=True)
@ -199,7 +205,7 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
return User.get(user_name=login) return User.get(user_name=login)
def has_password(self): def has_password(self):
return bool(self.password) return self.password is not None
def check_password(self, password): def check_password(self, password):
if password != self.password: if password != self.password:

51
poetry.lock generated
View file

@ -1051,6 +1051,23 @@ files = [
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
] ]
[[package]]
name = "passlib"
version = "1.7.4"
description = "comprehensive password hashing framework supporting over 30 schemes"
optional = true
python-versions = "*"
files = [
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
]
[package.extras]
argon2 = ["argon2-cffi (>=18.2.0)"]
bcrypt = ["bcrypt (>=3.1.0)"]
build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
totp = ["cryptography"]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.0.0" version = "4.0.0"
@ -1886,6 +1903,34 @@ sqlalchemy = ">=0.7"
[package.extras] [package.extras]
dev = ["pytest"] dev = ["pytest"]
[[package]]
name = "sqlalchemy-utils"
version = "0.41.1"
description = "Various utility functions for SQLAlchemy."
optional = true
python-versions = ">=3.6"
files = [
{file = "SQLAlchemy-Utils-0.41.1.tar.gz", hash = "sha256:a2181bff01eeb84479e38571d2c0718eb52042f9afd8c194d0d02877e84b7d74"},
{file = "SQLAlchemy_Utils-0.41.1-py3-none-any.whl", hash = "sha256:6c96b0768ea3f15c0dc56b363d386138c562752b84f647fb8d31a2223aaab801"},
]
[package.dependencies]
SQLAlchemy = ">=1.3"
[package.extras]
arrow = ["arrow (>=0.3.4)"]
babel = ["Babel (>=1.3)"]
color = ["colour (>=0.0.4)"]
encrypted = ["cryptography (>=0.6)"]
intervals = ["intervals (>=0.7.1)"]
password = ["passlib (>=1.6,<2.0)"]
pendulum = ["pendulum (>=2.0.5)"]
phone = ["phonenumbers (>=5.9.2)"]
test = ["Jinja2 (>=2.3)", "Pygments (>=1.2)", "backports.zoneinfo", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "isort (>=4.2.2)", "pg8000 (>=1.12.4)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (>=2.7.1)", "python-dateutil (>=2.6)", "pytz (>=2014.2)"]
test-all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "backports.zoneinfo", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (>=2.7.1)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)"]
timezone = ["python-dateutil"]
url = ["furl (>=0.4.1)"]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.10.2" version = "0.10.2"
@ -2055,14 +2100,14 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link
testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
[extras] [extras]
all = ["authlib", "email_validator", "flask-babel", "flask-themer", "pycountry", "python-ldap", "pytz", "sentry-sdk", "sqlalchemy", "sqlalchemy-json", "toml"] all = ["authlib", "email_validator", "flask-babel", "flask-themer", "passlib", "pycountry", "python-ldap", "pytz", "sentry-sdk", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils", "toml"]
front = ["email_validator", "flask-babel", "flask-themer", "pycountry", "pytz", "toml"] front = ["email_validator", "flask-babel", "flask-themer", "pycountry", "pytz", "toml"]
ldap = ["python-ldap"] ldap = ["python-ldap"]
oidc = ["authlib"] oidc = ["authlib"]
sentry = ["sentry-sdk"] sentry = ["sentry-sdk"]
sql = ["sqlalchemy", "sqlalchemy-json"] sql = ["passlib", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "b3561cb0465972869503d18840bb7a3da62a75c447b827b0db79183f49c4e31f" content-hash = "7372f06aa097a115c189932bb0afea25c21cfdbdefd81da5b95fab228da50217"

View file

@ -61,8 +61,10 @@ python-ldap = {version = "^3.4.0", optional=true}
sentry-sdk = {version = "<2", optional=true, extras=["flask"]} sentry-sdk = {version = "<2", optional=true, extras=["flask"]}
# extra : sql # extra : sql
sqlalchemy-json = {version = "^0.7.0", optional=true} passlib = {version = "^1.7.4", optional=true}
sqlalchemy = {version = "^2.0.23", optional=true} sqlalchemy = {version = "^2.0.23", optional=true}
sqlalchemy-json = {version = "^0.7.0", optional=true}
sqlalchemy-utils = {version = "^0.41.1", optional=true}
[tool.poetry.group.doc] [tool.poetry.group.doc]
optional = true optional = true
@ -117,14 +119,17 @@ sentry = [
"sentry-sdk", "sentry-sdk",
] ]
sql = [ sql = [
"passlib",
"sqlalchemy", "sqlalchemy",
"sqlalchemy-json", "sqlalchemy-json",
"sqlalchemy-utils",
] ]
all = [ all = [
"click", "click",
"email_validator", "email_validator",
"flask-babel", "flask-babel",
"flask-themer", "flask-themer",
"passlib",
"pycountry", "pycountry",
"pytz", "pytz",
"toml", "toml",
@ -133,6 +138,7 @@ all = [
"sentry-sdk", "sentry-sdk",
"sqlalchemy", "sqlalchemy",
"sqlalchemy-json", "sqlalchemy-json",
"sqlalchemy-utils",
] ]
[tool.poetry.scripts] [tool.poetry.scripts]

View file

@ -28,22 +28,24 @@ def test_user_get_from_login(testclient, user, backend):
def test_user_has_password(testclient, backend): def test_user_has_password(testclient, backend):
u = models.User( user = models.User(
formatted_name="Temp User", formatted_name="Temp User",
family_name="Temp", family_name="Temp",
user_name="temp", user_name="temp",
emails=["john@doe.com"], emails=["john@doe.com"],
) )
u.save() user.save()
assert not u.has_password() assert user.password is None
assert not user.has_password()
u.password = "foobar" user.password = "foobar"
u.save() user.save()
assert u.has_password() assert user.password is not None
assert user.has_password()
u.delete() user.delete()
def test_user_set_and_check_password(testclient, user, backend): def test_user_set_and_check_password(testclient, user, backend):