Got interested and made a convoluted example: defined a LoginManager
class to handle both creating and logging users using a list on an external document. This class should be kept in it's own file, which would then be imported on both the code file where the new users are created and on the one where they are verified (see examples on the code).
The class initiator gets a path to a file as an argument, as long as it points to the same file in both code files it should be ok.
I used argon2
as the hashing algorithm, although I must admit I'm new to cryptography and so you should look upon my choice with a grain of salt.
import argon2
from pathlib import Path
import re
class LoginManager(object):
"""
Parameters
----------
database : str
File (and path to it) that holds the users and corresponding hashed
passwords.
Examples
--------
Simple `LoginManager`:
>>> temp_database = 'temp_database.txt'
>>> Login1 = LoginManager(temp_database)
>>> Login1.add_user('super_user', 's3kr3tp4ssw0rd')
>>> Login1.login('super_user', 's3kr3tp4ssw0rd')
True
>>> Login1.login('super_user', 't0t411ywr0ng') # wrong password
False
>>> Login1.login('LameUser', 's3kr3tp4ssw0rd') # not existing user
False
New `LoginManager` with the same database:
>>> Login2 = LoginManager(temp_database)
>>> Login2.login('super_user', 's3kr3tp4ssw0rd') # was on Login1
True
>>> Login2.login('super_user', 't0t411ywr0ng') # wrong password
False
>>> Login2.login('LameUser', 't0t411ywr0ng') # not existing user
False
Clean `temp_database` if testing with `doctest`:
>>> import os
>>> os.remove(temp_database)
Notes
-----
* Notice that adding new users to either LoginManager in the example will
not share the new ones between them! Only the users existing at the time
of the creation will be in the LoginManager, so colisions must be must be
avoided by not having two pointing to the same file at the same.
* See more details on `argon2` here:
https://argon2-cffi.readthedocs.io/en/stable/api.html#argon2.PasswordHasher
(also where I saw 's3kr3tp4ssw0rd' and 't0t411ywr0ng')
"""
def __init__(self, database=None):
"""
Create new `LoginManager`.
Notes
-----
Should have better verification whether given path points to a file or
a folder!
"""
if database is None:
database = 'database.txt'
self.database = Path(database)
# create file if it is not there:
if not self.database.is_file():
self.database.touch()
self._reload_database()
self.hasher = argon2.PasswordHasher(time_cost=2,
memory_cost=512,
parallelism=2,
hash_len=16,
salt_len=16,
encoding='utf-8')
self._hash_example = self.hasher.hash('default')
self._max_name_size = 10
def add_user(self, user, password):
"""
Add new user to the `LoginManager` and update `database` file.
Raises `ValueError` if the user already exists or is invalid.
Examples
--------
>>> temp_database = 'temp_database_user.txt'
>>> Login1 = LoginManager(temp_database)
>>> Login1.add_user('super*user', 's3kr3tp4ssw0rd')
Traceback (most recent call last):
ValueError: Invalid characters in user name!
Use only A-Z, a-z, 0-9 and `.`, `-` or `_`.
>>> Login1.add_user('UltraLongUserName', 's3kr3tp4ssw0rd')
Traceback (most recent call last):
ValueError: User name "UltraLongUs..." too long!
Clean `temp_database` if testing with `doctest`:
>>> import os
>>> os.remove(temp_database)
Notes
-----
Does not check users added to the databse file after the `LoginManager`
was created!
"""
self._is_username_valid(user) # raises ValueError if it's not
hashed = self.hasher.hash(password)
self.users[user] = hashed
with self.database.open('ba') as database:
database.write('\t'.join([user.ljust(self._max_name_size),
hashed,
'\r\n']))
# The '\r\n' at the end ensures there's always a new empty line
# after the lattest password, and eases the split of user name and
# password afterwards using '\t' when importing the list.
# The .ljust(self._max_name_size) pads the username with spaces.
# The binary mode is used to ensure future changes are valid (namely
# to allow the use of seek with negative values whithin the file.
def login(self, user, password):
"""
Return `True` if the user/password pair is valid, `False` otherwise.
"""
try:
return self.hasher.verify(self.users.get(user, self._hash_example),
password)
except argon2.exceptions.VerifyMismatchError:
return False
def change_password(self, user, old_password, new_password):
"""
Change password of existing user.
Examples
--------
>>> temp_database = 'temp_database_change_pass.txt'
>>> Login1 = LoginManager(temp_database)
>>> Login1.add_user('super_user', 's3kr3tp4ssw0rd')
>>> Login1.add_user('LameUser', 't0t411ywr0ng')
>>> Login1.add_user('banana', '1234567890')
Test changes to first user:
>>> Login1.login('super_user', 's3kr3tp4ssw0rd')
True
>>> Login1.change_password('super_user',
... 's3kr3tp4ssw0rd',
... 'n3ws3kr3tp4ssw0rd')
>>> Login1.login('super_user', 's3kr3tp4ssw0rd')
False
>>> Login1.login('super_user', 'n3ws3kr3tp4ssw0rd')
True
Test changes to last user:
>>> Login1.change_password('banana',
... '1234567890',
... 'n3ws3kr3tp4ssw0rd')
>>> Login1.login('banana', '1234567890')
False
>>> Login1.login('banana', 'n3ws3kr3tp4ssw0rd')
True
Test changes to a middle user:
>>> Login1.change_password('LameUser',
... 't0t411ywr0ng',
... 'n3ws3kr3tp4ssw0rd')
>>> Login1.login('banana', 't0t411ywr0ng')
False
>>> Login1.login('banana', 'n3ws3kr3tp4ssw0rd')
True
Make sure changes to file are valid:
>>> Login2 = LoginManager(temp_database)
>>> Login2.login('super_user', 'n3ws3kr3tp4ssw0rd')
True
Clean `temp_database` if testing with `doctest`:
>>> import os
>>> os.remove(temp_database)
Notes
-----
If a contact is available to reach the user, it should be used to warn
him of an attempt to change the password.
"""
if self.login(user, old_password):
with self.database.open('br+') as database:
# find user name
line = ' '
while line != '':
line = database.readline()
if line[:self._max_name_size].rstrip() == user:
new_hash = self.hasher.hash(new_password)
database.seek(-len(self._hash_example)-3, 1)
database.write('\t'.join([new_hash, '\r\n']))
self.users[user] = new_hash
break
def _reload_database(self):
self.users = {} # could use a more intuitive name
with self.database.open('r') as database:
lines = database.readlines()
for user_pass in lines:
user, pass_hash, new_line = user_pass.split('\t')
self.users[user.rstrip()] = pass_hash
def _is_username_valid(self, user_name):
"""
Check if user name is valid, raise `ValueError` if it's not.
"""
if user_name in self.users.keys():
raise ValueError('User "%s" already exists!' % (user_name,))
if len(user_name) > self._max_name_size:
raise ValueError('User name "%s..." too long!' %
(user_name[:self._max_name_size+1],))
# From https://stackoverflow.com/questions/1323364/
search = re.compile(r'[^A-Za-z0-9._-]').search
if bool(search(user_name)):
raise ValueError('Invalid characters in user name!\n'
'Use only A-Z, a-z, 0-9 and `.`, `-` or `_`.')
def _test():
"""
Test functions using the doctext examples.
"""
import doctest
doctest.testmod(verbose=False)
if __name__ == "__main__":
# use the examples in documentation to test the code.
_test()
You (and anyone) are free to use and alter at leisure, but don't take this as a good practice example - just an amateur's opinion on the subject.