358 lines
9.0 KiB
Python
358 lines
9.0 KiB
Python
# Copyright (c) 2020. BioPro. Scientific.
|
|
|
|
"""
|
|
Command line
|
|
------------
|
|
|
|
example password hash and verify.
|
|
|
|
Usage ::
|
|
|
|
env PYTHONPATH=RaspBerryPi3/python python -m biopro.file.user.password PASSWORD [HASH]
|
|
|
|
|
|
Reference
|
|
---------
|
|
|
|
https://www.vitoshacademy.com/hashing-passwords-in-python/
|
|
|
|
"""
|
|
import abc
|
|
import binascii
|
|
import hashlib
|
|
from pathlib import Path
|
|
from typing import List, Dict, Iterable, Optional
|
|
|
|
try:
|
|
import secrets
|
|
except ImportError:
|
|
from . import _secrets as secrets
|
|
|
|
from biopro.text import *
|
|
from biopro.util.lock import *
|
|
from biopro.util.text import part_suffix
|
|
|
|
# https://en.wikipedia.org/wiki/List_of_the_most_common_passwords
|
|
_COMMON_ = {
|
|
# number
|
|
'1234'
|
|
'123123'
|
|
'4321'
|
|
'5678'
|
|
'abcd',
|
|
# keyboard layout
|
|
'qwerty',
|
|
'azerty',
|
|
'qwerasdf',
|
|
'qazwsx',
|
|
'1q2w3e4r',
|
|
# meaning word
|
|
'admin',
|
|
'baseball',
|
|
'charlie',
|
|
'donald',
|
|
'dragon',
|
|
'football',
|
|
'iloveyou',
|
|
'master',
|
|
'monkey',
|
|
'password',
|
|
'princess',
|
|
'qwertyuiop',
|
|
'sunshine',
|
|
'superman',
|
|
'whatever',
|
|
'welcome',
|
|
# symbol
|
|
'!@#$%^&*',
|
|
# 注音
|
|
'au3a83', # 密碼
|
|
# XXX load from file?
|
|
}
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
"""hash password.
|
|
|
|
:param password: user password input
|
|
:return: hashed password
|
|
"""
|
|
salt = secrets.token_bytes(64)
|
|
pw_hash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), salt, 100000)
|
|
return binascii.hexlify(salt + pw_hash).decode('ascii')
|
|
|
|
|
|
def verify_password(stored: str, provided: str) -> bool:
|
|
"""
|
|
|
|
:param stored: hashed password
|
|
:param provided: password
|
|
:return:
|
|
"""
|
|
stored = binascii.unhexlify(stored.encode('ascii'))
|
|
salt = stored[:64]
|
|
pw0_hash = stored[64:]
|
|
pw1_hash = hashlib.pbkdf2_hmac('sha512', provided.encode('utf-8'), salt, 100000)
|
|
return pw0_hash == pw1_hash
|
|
|
|
|
|
class PasswordAPI(metaclass=abc.ABCMeta):
|
|
"""user account manager API"""
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def password_file(self) -> Path:
|
|
"""password storage file path"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_list(self) -> List[str]:
|
|
"""user stored in file"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_hash(self, user: str) -> str:
|
|
"""get user hashed password"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_verify(self, user: str, password: str) -> bool:
|
|
"""verify user password.
|
|
|
|
This method don't raise any error, no matter the incorrect password or un-existed user.
|
|
|
|
:param user:
|
|
:param password:
|
|
:return:
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_add(self, user: str, password: str):
|
|
"""add user"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def check_password_strength(self, password: str) -> Optional[str]:
|
|
"""check the password is strength enough.
|
|
|
|
:param password:
|
|
:return: suggestion message, None when pass
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_change(self, user: str, password: str, new_password: str):
|
|
"""change user password.
|
|
|
|
:param user:
|
|
:param password: old password, or temporary password generated by :func:`user_forget`
|
|
:param new_password: new password.
|
|
:return:
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_forget(self, user: str) -> str:
|
|
"""forget user password.
|
|
|
|
This method would generate short, temporary password for user, which they can use it to
|
|
change the password by method :func:`user_change`.
|
|
|
|
The temporary password will be kept by manager until the owned user change their password,
|
|
or controller server restart the process.
|
|
|
|
The temporary password aren't stored into password file.
|
|
|
|
:param user:
|
|
:return: new temp password
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def user_del(self, user: str, password: str):
|
|
"""delete user"""
|
|
pass
|
|
|
|
|
|
class PasswordManager(PasswordAPI, Synchronized, object):
|
|
"""user account manager"""
|
|
|
|
__slots__ = '__filepath', '__password', '__forget'
|
|
|
|
def __init__(self, filepath: Path):
|
|
Synchronized.__init__(self)
|
|
|
|
self.__filepath = filepath
|
|
self.__password = self.__load_file(self.__filepath)
|
|
self.__forget: Dict[str, str] = {}
|
|
|
|
def __init_subclass__(cls) -> None:
|
|
# no subclass
|
|
raise RuntimeError()
|
|
|
|
@staticmethod
|
|
def __load_file(filepath: Path) -> Dict[str, str]:
|
|
if not filepath.exists():
|
|
return {}
|
|
|
|
if not filepath.is_file():
|
|
raise IsADirectoryError(str(filepath))
|
|
|
|
ret = {}
|
|
|
|
with filepath.open('r') as f:
|
|
for line in f:
|
|
p, u = part_suffix(line.strip(), ' ')
|
|
ret[u] = p
|
|
|
|
return ret
|
|
|
|
@staticmethod
|
|
def __sync_file(filepath: Path, data: Dict[str, str]):
|
|
d: Path = filepath.parent
|
|
if not d.exists():
|
|
d.mkdir(parents=True)
|
|
|
|
with filepath.open('w') as f:
|
|
for u in sorted(data.keys()):
|
|
p = data[u]
|
|
|
|
print(p, u, file=f)
|
|
|
|
@property
|
|
def password_file(self) -> Path:
|
|
return self.__filepath
|
|
|
|
def user_list(self) -> List[str]:
|
|
return list(self.__password.keys())
|
|
|
|
def user_hash(self, user: str) -> str:
|
|
try:
|
|
return self.__password[user]
|
|
except KeyError:
|
|
pass
|
|
|
|
raise ValueError(USER_NOT_FOUND, user)
|
|
|
|
def user_verify(self, user: str, password: str, strict=True) -> bool:
|
|
"""verify user password.
|
|
|
|
This method don't raise any error, no matter the incorrect password or un-existed user.
|
|
|
|
:param user:
|
|
:param password:
|
|
:param strict: do not verify the temp password if their forget.
|
|
:return:
|
|
"""
|
|
try:
|
|
if verify_password(self.__password[user], password):
|
|
return True
|
|
|
|
if not strict and verify_password(self.__forget[user], password):
|
|
return True
|
|
|
|
return False
|
|
except KeyError:
|
|
return False
|
|
|
|
@synchronized
|
|
def user_add(self, user: str, password: str):
|
|
if user in self.__password:
|
|
raise ValueError(USER_HAS_BEEN_USED, user)
|
|
|
|
message = self.check_password_strength(password, user)
|
|
if message is not None:
|
|
raise ValueError(PASSWORD_TOO_WEAK, message)
|
|
|
|
self.__password[user] = hash_password(password)
|
|
self.__sync_file(self.__filepath, self.__password)
|
|
|
|
def check_password_strength(self, password: str, user_name: str = None) -> Optional[str]:
|
|
password = password.lower()
|
|
|
|
if password == user_name.lower():
|
|
return 'same as user name'
|
|
|
|
if len(password) < 8:
|
|
return 'password too short'
|
|
|
|
if len(set(password)) < 4:
|
|
return 'password too simple'
|
|
|
|
if all([c in '0123456789' for c in password]):
|
|
return 'password should be contain alphabet characters'
|
|
|
|
if password in _COMMON_:
|
|
return 'password too common'
|
|
|
|
if any([c in password for c in _COMMON_]):
|
|
return 'password too common'
|
|
|
|
return None
|
|
|
|
@synchronized
|
|
def user_change(self, user: str, password: str, new_password: str):
|
|
# raise error if user not existed.
|
|
self.user_hash(user)
|
|
|
|
if not self.user_verify(user, password, strict=False):
|
|
raise RuntimeError(PASSWORD_NOT_CORRECT, user)
|
|
|
|
message = self.check_password_strength(new_password, user)
|
|
if message is not None:
|
|
raise ValueError(PASSWORD_TOO_WEAK, message)
|
|
|
|
self.__password[user] = hash_password(new_password)
|
|
self.__sync_file(self.__filepath, self.__password)
|
|
|
|
# remove user if their forget the password
|
|
try:
|
|
del self.__forget[user]
|
|
except KeyError:
|
|
pass
|
|
|
|
def user_forget(self, user: str) -> str:
|
|
new_password = secrets.token_urlsafe(8)
|
|
self.__forget[user] = hash_password(new_password)
|
|
return new_password
|
|
|
|
@synchronized
|
|
def user_del(self, user: str, password: str):
|
|
# raise error if user not existed.
|
|
self.user_hash(user)
|
|
|
|
if not self.user_verify(user, password, strict=False):
|
|
raise RuntimeError(PASSWORD_NOT_CORRECT, user)
|
|
|
|
del self.__password[user]
|
|
self.__sync_file(self.__filepath, self.__password)
|
|
|
|
# remove user if their forget the password
|
|
try:
|
|
del self.__forget[user]
|
|
except KeyError:
|
|
pass
|
|
|
|
def __dir__(self) -> Iterable[str]:
|
|
raise RuntimeError()
|
|
|
|
def __str__(self):
|
|
raise RuntimeError()
|
|
|
|
def __repr__(self):
|
|
raise RuntimeError()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
|
|
if len(sys.argv) == 1:
|
|
print(sys.argv[0], 'PASSWORD', '[HASH]')
|
|
elif len(sys.argv) == 2:
|
|
print(hash_password(sys.argv[1]))
|
|
elif len(sys.argv) == 3:
|
|
print(verify_password(sys.argv[2], sys.argv[1]))
|
|
else:
|
|
raise ValueError('unknown argument : ' + sys.argv[3])
|