Skip to content

Commit

Permalink
Change default password hash to argon2 (#982)
Browse files Browse the repository at this point in the history
close #980
  • Loading branch information
jwag956 authored May 29, 2024
1 parent 10634c6 commit 4e1a66e
Show file tree
Hide file tree
Showing 11 changed files with 75 additions and 74 deletions.
12 changes: 10 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@ Version 5.5.0

Released TBD

Features
++++++++
Features & Improvements
+++++++++++++++++++++++
- (:issue:`956`) Add support for changing registered user's email (:py:data:`SECURITY_CHANGE_EMAIL`).
- (:pr:`xxx`) Change default password hash to argon2 (was bcrypt). See below for details.

Fixes
+++++
- (:pr:`972`) Set :py:data:`SECURITY_CSRF_COOKIE` at beginning (GET /login) of authentication
ritual - just as we return the CSRF token. (thanks @e-goto)

Backwards Compatibility Concerns
+++++++++++++++++++++++++++++++++
- Notes around the change to argon2 as the default password hash:
- applications should add the argon2_cffi package to their requirements (it is included in the flask_security[common] extras).
- leave bcrypt installed to that old passwords still work.
- the default configuration will re-hash passwords with argon2 upon first use.

Version 5.4.3
-------------

Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Goals
* WebAuthn support (5.0)
* Two-Factor recovery codes (5.0)
* First-class support for username as identity (4.1)
* Support for fresheness decorator to ensure sensitive operations have new authentication (4.0)
* Support for freshness decorator to ensure sensitive operations have new authentication (4.0)
* Support for email normalization and validation (4.0)
* Unified signin (username, phone, passwordless) feature (3.4)

Expand Down
12 changes: 7 additions & 5 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ These configuration keys are used globally across all features.
.. py:data:: SECURITY_PASSWORD_HASH
Specifies the password hash algorithm to use when hashing passwords.
Recommended values for production systems are ``bcrypt``, ``argon2``, ``sha512_crypt``, or
Recommended values for production systems are ``argon2``, ``bcrypt``, or
``pbkdf2_sha512``. Some algorithms require the installation of a backend package (e.g. `bcrypt`_, `argon2`_).

Default: ``"bcrypt"``.
Default: ``"argon2"``.

.. py:data:: SECURITY_PASSWORD_SCHEMES
Expand Down Expand Up @@ -134,9 +134,11 @@ These configuration keys are used globally across all features.

.. py:data:: SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS
Pass additional options to the various hashing methods. This is a
dict of the form ``{<scheme>__<option>: <value>, ..}``
e.g. {"argon2__rounds": 10}.
Pass additional options through ``passlib`` to the various hashing methods.
This is a dict of the form ``{<scheme>__<option>: <value>, ..}``
e.g. {"argon2__time_cost": 3}.

Default: ``{}``

.. versionadded:: 3.3.1

Expand Down
21 changes: 16 additions & 5 deletions docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@ control (such as per-object), you can refer to the Flask-Principal `documentatio
Password Hashing
----------------

Password hashing is enabled with `passlib`_. Passwords are hashed with the
`bcrypt`_ function by default but you can easily configure the hashing
algorithm. You should **always use a hashing algorithm** in your production
environment. Hash algorithms not listed in ``SECURITY_PASSWORD_SINGLE_HASH``
Password hashing is implemented using `passlib`_. Passwords are hashed with the
`argon2`_ function by default but you can easily configure other hashing
algorithms.
For any given hashing algorithm, consult its documentation on what
options/values are recommended. For argon2, the `argon2_cffi`_ package
keeps its default options up to date with `RFC9106`_, and should be suitable for most
applications. The `OWASP Password Storage Cheat Sheet <owasp_pass_cheat>`_ also
has a lot of useful suggestions.

You should **always use a hashing algorithm** in your production
environment. Hash algorithms not listed in :data:`SECURITY_PASSWORD_SINGLE_HASH`
will be double hashed - first an HMAC will be computed, then the selected hash
function will be used. In this case - you must provide a ``SECURITY_PASSWORD_SALT``.
function will be used. In this case - you must provide a :data:`SECURITY_PASSWORD_SALT`.
A good way to generate this is::

secrets.SystemRandom().getrandbits(128)
Expand Down Expand Up @@ -308,6 +315,10 @@ in the `examples` directory.
.. _documentation on this topic: http://packages.python.org/Flask-Principal/#granular-resource-protection
.. _passlib: https://passlib.readthedocs.io/en/stable/
.. _totp secret: https://passlib.readthedocs.io/en/stable/narr/totp-tutorial.html#overview
.. _argon2: https://en.wikipedia.org/wiki/Argon2
.. _argon2_cffi: https://pypi.org/project/argon2-cffi/
.. _RFC9106: https://www.rfc-editor.org/rfc/rfc9106.html
.. _owasp_pass_cheat: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
.. _bcrypt: https://en.wikipedia.org/wiki/Bcrypt
.. _PyQRCode: https://pypi.python.org/pypi/PyQRCode/
.. _Wikipedia: https://en.wikipedia.org/wiki/Multi-factor_authentication
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Supported extras are:

* ``babel`` - Translation services. It will install babel and Flask-Babel.
* ``fsqla`` - Use flask-sqlalchemy and sqlalchemy as your storage interface.
* ``common`` - Install Flask-Mailman, bcrypt (the default password hash), and bleach.
* ``common`` - Install Flask-Mailman, argon2 (the default password hash), and bleach.
* ``mfa`` - Install packages used for multi-factor (two-factor, unified signin, WebAuthn):
cryptography, qrcode, phonenumberslite (note that for SMS you still need
to pick an SMS provider and install appropriate packages), and webauthn.
Expand Down
16 changes: 6 additions & 10 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ There are some complete (but simple) examples available in the *examples* direct
for some missing packages.

.. note::
The default :data:`SECURITY_PASSWORD_HASH` is "bcrypt" - so be sure to install bcrypt.
If you opt for a different hash e.g. "argon2" you will need to install the appropriate package e.g. `argon_cffi`_.
The default :data:`SECURITY_PASSWORD_HASH` is "argon2" - so be sure to install `argon_cffi`_.
If you opt for a different hash e.g. "bcrypt" you will need to install the appropriate package.
.. danger::
The examples below place secrets in source files. Never do this for your application
especially if your source code is placed in a public repo. How you pass in secrets
Expand Down Expand Up @@ -67,8 +67,7 @@ possible using Flask-SQLAlchemy and the built-in model mixins:

# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')

# have session and remember cookie be samesite (flask/flask_login)
Expand Down Expand Up @@ -167,8 +166,7 @@ and models.py.

# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')
# Don't worry if email has findable domain
app.config["SECURITY_EMAIL_VALIDATOR_ARGS"] = {"check_deliverability": False}
Expand Down Expand Up @@ -321,8 +319,7 @@ local MongoDB instance):

# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')
# Don't worry if email has findable domain
app.config["SECURITY_EMAIL_VALIDATOR_ARGS"] = {"check_deliverability": False}
Expand Down Expand Up @@ -413,8 +410,7 @@ possible using Peewee:

# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')

app.config['DATABASE'] = {
Expand Down
3 changes: 1 addition & 2 deletions docs/two_factor_configurations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ possible using SQLAlchemy:
app.config['DEBUG'] = True
# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
Expand Down
7 changes: 3 additions & 4 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
"I18N_DOMAIN": "flask_security",
"I18N_DIRNAME": "builtin",
"EMAIL_VALIDATOR_ARGS": None,
"PASSWORD_HASH": "bcrypt",
"PASSWORD_HASH": "argon2",
"PASSWORD_SALT": None,
"PASSWORD_SINGLE_HASH": {
"django_argon2",
Expand All @@ -160,9 +160,8 @@
"plaintext",
],
"PASSWORD_HASH_OPTIONS": {}, # Deprecated at passlib 1.7
"PASSWORD_HASH_PASSLIB_OPTIONS": {
"argon2__rounds": 10 # 1.7.1 default is 2.
}, # >= 1.7.1 method to pass options.
"PASSWORD_HASH_PASSLIB_OPTIONS": {}, # passlib >= 1.7.1 method to pass options
# (as part of CryptoContext.using)
"PASSWORD_LENGTH_MIN": 8,
"PASSWORD_COMPLEXITY_CHECKER": None,
"PASSWORD_CHECK_BREACHED": False,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ dependencies = [
[project.optional-dependencies]
babel = ["babel>=2.12.1", "flask_babel>=3.1.0"]
fsqla = ["flask_sqlalchemy>=3.0.3", "sqlalchemy>=2.0.12", "sqlalchemy-utils>=0.41.1"]
common = ["bcrypt>=4.0.1", "flask_mailman>=0.3.0", "bleach>=6.0.0"]
common = ["argon2_cffi>=21.3.0", "bcrypt>=4.0.1", "flask_mailman>=0.3.0", "bleach>=6.0.0"]
mfa = ["cryptography>=40.0.2", "qrcode>=7.4.2", "phonenumberslite>=8.13.11", "webauthn>=2.0.0"]
low = [
# Lowest supported versions
Expand Down
68 changes: 27 additions & 41 deletions tests/test_hashing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
~~~~~~~~~~~~
hashing tests
:copyright: (c) 2019-2024 by J. Christopher Wagner (jwag).
:license: MIT, see LICENSE for more details.
"""

import timeit
Expand All @@ -15,26 +18,33 @@
from flask_security.utils import hash_password, verify_password, get_hmac


def test_verify_password_bcrypt_double_hash(app, sqlalchemy_datastore):
def test_verify_password_double_hash(app, sqlalchemy_datastore):
init_app_with_options(
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {"argon2__rounds": 1},
"SECURITY_PASSWORD_SALT": "salty",
"SECURITY_PASSWORD_SINGLE_HASH": False,
},
)
with app.app_context():
assert verify_password("pass", hash_password("pass"))
hashed_pwd = hash_password("pass")
assert verify_password("pass", hashed_pwd)
assert "t=1" in hashed_pwd

# Verify double hash
assert verify_password("pass", argon2.hash(get_hmac("pass")))

def test_verify_password_bcrypt_single_hash(app, sqlalchemy_datastore):

def test_verify_password_single_hash(app, sqlalchemy_datastore):
init_app_with_options(
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {"argon2__rounds": 1},
"SECURITY_PASSWORD_SALT": None,
"SECURITY_PASSWORD_SINGLE_HASH": True,
},
Expand All @@ -48,11 +58,12 @@ def test_verify_password_single_hash_list(app, sqlalchemy_datastore):
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {"argon2__rounds": 1},
"SECURITY_PASSWORD_SALT": "salty",
"SECURITY_PASSWORD_SINGLE_HASH": ["django_pbkdf2_sha256", "plaintext"],
"SECURITY_PASSWORD_SCHEMES": [
"bcrypt",
"argon2",
"pbkdf2_sha256",
"django_pbkdf2_sha256",
"plaintext",
Expand All @@ -73,9 +84,10 @@ def test_verify_password_backward_compatibility(app, sqlalchemy_datastore):
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {"argon2__rounds": 1},
"SECURITY_PASSWORD_SINGLE_HASH": False,
"SECURITY_PASSWORD_SCHEMES": ["bcrypt", "plaintext"],
"SECURITY_PASSWORD_SCHEMES": ["argon2", "plaintext"],
},
)
with app.app_context():
Expand All @@ -85,26 +97,15 @@ def test_verify_password_backward_compatibility(app, sqlalchemy_datastore):
assert verify_password("pass", plaintext.hash("pass"))


def test_verify_password_bcrypt_rounds_too_low(app, sqlalchemy_datastore):
with raises(ValueError) as exc_msg:
init_app_with_options(
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_SALT": "salty",
"SECURITY_PASSWORD_HASH_OPTIONS": {"bcrypt": {"rounds": 3}},
},
)
assert all(s in str(exc_msg.value) for s in ["rounds", "too low"])


def test_login_with_bcrypt_enabled(app, sqlalchemy_datastore):
init_app_with_options(
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {
"bcrypt__rounds": 4, # minimum so test is faster
},
"SECURITY_PASSWORD_SALT": "salty",
"SECURITY_PASSWORD_SINGLE_HASH": False,
},
Expand All @@ -119,44 +120,29 @@ def test_missing_hash_salt_option(app, sqlalchemy_datastore):
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "bcrypt",
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_SALT": None,
"SECURITY_PASSWORD_SINGLE_HASH": False,
},
)


def test_verify_password_argon2(app, sqlalchemy_datastore):
init_app_with_options(
app,
sqlalchemy_datastore,
**{"SECURITY_PASSWORD_HASH": "argon2", "SECURITY_PASSWORD_SINGLE_HASH": False},
)
with app.app_context():
hashed_pwd = hash_password("pass")
assert verify_password("pass", hashed_pwd)
assert "t=10" in hashed_pwd

# Verify double hash
assert verify_password("pass", argon2.hash(get_hmac("pass")))


def test_verify_password_argon2_opts(app, sqlalchemy_datastore):
init_app_with_options(
app,
sqlalchemy_datastore,
**{
"SECURITY_PASSWORD_HASH": "argon2",
"SECURITY_PASSWORD_HASH_PASSLIB_OPTIONS": {
"argon2__rounds": 4,
"argon2__rounds": 1,
"argon2__salt_size": 16,
"argon2__hash_len": 16,
},
},
)
with app.app_context():
hashed_pwd = hash_password("pass")
assert "t=4" in hashed_pwd
assert "t=1" in hashed_pwd
assert verify_password("pass", hashed_pwd)


Expand Down
4 changes: 2 additions & 2 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,11 @@ def test_change_hash_type(app, sqlalchemy_datastore):
**{
"SECURITY_PASSWORD_HASH": "plaintext",
"SECURITY_PASSWORD_SALT": None,
"SECURITY_PASSWORD_SCHEMES": ["bcrypt", "plaintext"],
"SECURITY_PASSWORD_SCHEMES": ["argon2", "plaintext"],
},
)

app.config["SECURITY_PASSWORD_HASH"] = "bcrypt"
app.config["SECURITY_PASSWORD_HASH"] = "argon2"
app.config["SECURITY_PASSWORD_SALT"] = "salty"

app.security = Security(
Expand Down

0 comments on commit 4e1a66e

Please sign in to comment.