Changed password setup to use libsodium and argon2id

* Regular SHA256 hashing for passwords is inadequate as modern GPU's can hash generate billions of hashes per second.
* Added code to auto migrate old passwords to the new password_hash if the existing password_hash matches either of the legacy password generation schemes (SHA1 or SHA256).
* Added migration to support new password_hash format length
* Added auth tests
This commit is contained in:
ReAnzu 2018-02-24 23:45:00 -06:00
parent a1fbeb91a0
commit 0cd5600163
7 changed files with 110 additions and 15 deletions

View file

@ -11,3 +11,4 @@ scipy>=0.18.1
elasticsearch>=5.0.0 elasticsearch>=5.0.0
elasticsearch-dsl>=5.0.0 elasticsearch-dsl>=5.0.0
scikit-image>=0.12 scikit-image>=0.12
pynacl>=1.2.1

View file

@ -1,8 +1,11 @@
import hashlib import hashlib
import random import random
from collections import OrderedDict from collections import OrderedDict
from szurubooru import config, model, errors from nacl.exceptions import InvalidkeyError
from szurubooru import config, model, errors, db
from szurubooru.func import util from szurubooru.func import util
from nacl.pwhash import argon2id, verify
RANK_MAP = OrderedDict([ RANK_MAP = OrderedDict([
@ -17,7 +20,14 @@ RANK_MAP = OrderedDict([
def get_password_hash(salt: str, password: str) -> str: def get_password_hash(salt: str, password: str) -> str:
''' Retrieve new-style password hash. ''' """ Retrieve argon2id password hash."""
return argon2id.str(
(config.config['secret'] + salt + password).encode('utf8')
).decode('utf8')
def get_sha256_legacy_password_hash(salt: str, password: str) -> str:
""" Retrieve old-style sha256 password hash."""
digest = hashlib.sha256() digest = hashlib.sha256()
digest.update(config.config['secret'].encode('utf8')) digest.update(config.config['secret'].encode('utf8'))
digest.update(salt.encode('utf8')) digest.update(salt.encode('utf8'))
@ -25,8 +35,8 @@ def get_password_hash(salt: str, password: str) -> str:
return digest.hexdigest() return digest.hexdigest()
def get_legacy_password_hash(salt: str, password: str) -> str: def get_sha1_legacy_password_hash(salt: str, password: str) -> str:
''' Retrieve old-style password hash. ''' """ Retrieve old-style sha1 password hash."""
digest = hashlib.sha1() digest = hashlib.sha1()
digest.update(b'1A2/$_4xVa') digest.update(b'1A2/$_4xVa')
digest.update(salt.encode('utf8')) digest.update(salt.encode('utf8'))
@ -47,11 +57,22 @@ def create_password() -> str:
def is_valid_password(user: model.User, password: str) -> bool: def is_valid_password(user: model.User, password: str) -> bool:
assert user assert user
salt, valid_hash = user.password_salt, user.password_hash salt, valid_hash = user.password_salt, user.password_hash
possible_hashes = [
get_password_hash(salt, password), try:
get_legacy_password_hash(salt, password) return verify(user.password_hash.encode('utf8'),
] (config.config['secret'] + salt + password).encode('utf8'))
return valid_hash in possible_hashes except InvalidkeyError:
possible_hashes = [
get_sha256_legacy_password_hash(salt, password),
get_sha1_legacy_password_hash(salt, password)
]
if valid_hash in possible_hashes:
# Convert the user password hash to the new hash
user.password_hash = get_password_hash(salt, password)
db.session.commit()
return True
return False
def has_privilege(user: model.User, privilege_name: str) -> bool: def has_privilege(user: model.User, privilege_name: str) -> bool:

View file

@ -35,7 +35,11 @@ def run_migrations_offline():
''' '''
url = alembic_config.get_main_option('sqlalchemy.url') url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure( alembic.context.configure(
url=url, target_metadata=target_metadata, literal_binds=True) url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True
)
with alembic.context.begin_transaction(): with alembic.context.begin_transaction():
alembic.context.run_migrations() alembic.context.run_migrations()
@ -56,7 +60,9 @@ def run_migrations_online():
with connectable.connect() as connection: with connectable.connect() as connection:
alembic.context.configure( alembic.context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata) target_metadata=target_metadata,
compare_type=True
)
with alembic.context.begin_transaction(): with alembic.context.begin_transaction():
alembic.context.run_migrations() alembic.context.run_migrations()

View file

@ -0,0 +1,30 @@
'''
Alter the password_hash field to work with larger output. Particularly libsodium output for greater password security.
Revision ID: 9ef1a1643c2a
Created at: 2018-02-24 23:00:32.848575
'''
import sqlalchemy as sa
from alembic import op
revision = '9ef1a1643c2a'
down_revision = '02ef5f73f4ab'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('user', 'password_hash',
existing_type=sa.VARCHAR(length=64),
type_=sa.Unicode(length=128),
existing_nullable=False)
def downgrade():
op.alter_column('user', 'password_hash',
existing_type=sa.Unicode(length=128),
type_=sa.VARCHAR(length=64),
existing_nullable=False)

View file

@ -23,7 +23,7 @@ class User(Base):
last_login_time = sa.Column('last_login_time', sa.DateTime) last_login_time = sa.Column('last_login_time', sa.DateTime)
version = sa.Column('version', sa.Integer, default=1, nullable=False) version = sa.Column('version', sa.Integer, default=1, nullable=False)
name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True) name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True)
password_hash = sa.Column('password_hash', sa.Unicode(64), nullable=False) password_hash = sa.Column('password_hash', sa.Unicode(128), nullable=False)
password_salt = sa.Column('password_salt', sa.Unicode(32)) password_salt = sa.Column('password_salt', sa.Unicode(32))
email = sa.Column('email', sa.Unicode(64), nullable=True) email = sa.Column('email', sa.Unicode(64), nullable=True)
rank = sa.Column('rank', sa.Unicode(32), nullable=False) rank = sa.Column('rank', sa.Unicode(32), nullable=False)

View file

@ -115,11 +115,11 @@ def config_injector():
@pytest.fixture @pytest.fixture
def user_factory(): def user_factory():
def factory(name=None, rank=model.User.RANK_REGULAR, email='dummy'): def factory(name=None, rank=model.User.RANK_REGULAR, email='dummy', password_salt=None, password_hash=None):
user = model.User() user = model.User()
user.name = name or get_unique_name() user.name = name or get_unique_name()
user.password_salt = 'dummy' user.password_salt = password_salt or 'dummy'
user.password_hash = 'dummy' user.password_hash = password_hash or 'dummy'
user.email = email user.email = email
user.rank = rank user.rank = rank
user.creation_time = datetime(1997, 1, 1) user.creation_time = datetime(1997, 1, 1)

View file

@ -0,0 +1,37 @@
from szurubooru.func import auth
import pytest
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'secret': 'testSecret'})
def test_get_password_hash():
salt, password = ('testSalt', 'pass')
result = auth.get_password_hash(salt, password)
assert result
hash_parts = list(filter(lambda e: e is not None and e != '', result.split('$')))
assert len(hash_parts) == 5
assert hash_parts[0] == 'argon2id'
def test_get_sha256_legacy_password_hash():
salt, password = ('testSalt', 'pass')
result = auth.get_sha256_legacy_password_hash(salt, password)
assert result == '2031ac9631353ac9303719a7f808a24f79aa1d71712c98523e4bb4cce579428a'
def test_get_sha1_legacy_password_hash():
salt, password = ('testSalt', 'pass')
result = auth.get_sha1_legacy_password_hash(salt, password)
assert result == '1eb1f953d9be303a1b54627e903e6124cfb1245b'
def test_is_valid_password_auto_upgrades_user_password_hash_on_success_of_legacy_hash(user_factory):
salt, password = ('testSalt', 'pass')
legacy_password_hash = auth.get_sha256_legacy_password_hash(salt, password)
user = user_factory(password_salt=salt, password_hash=legacy_password_hash)
result = auth.is_valid_password(user, password)
assert result is True
assert user.password_hash != legacy_password_hash