diff --git a/alchemiscale/security/auth.py b/alchemiscale/security/auth.py index f4782ea1..a0a8403b 100644 --- a/alchemiscale/security/auth.py +++ b/alchemiscale/security/auth.py @@ -4,21 +4,73 @@ """ -from datetime import datetime, timedelta -from typing import Union, Optional import secrets +from datetime import datetime, timedelta +from typing import Optional, Union +import bcrypt from fastapi import HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt -from passlib.context import CryptContext from pydantic import BaseModel -from .models import Token, TokenData, CredentialedEntity +from .models import CredentialedEntity, Token, TokenData + +MAX_PASSWORD_SIZE = 4096 +_dummy_secret = "dummy" + + +class BcryptPasswordHandler(object): + rounds: int = 12 + ident: str = "$2b$" + salt: str = "" + checksum: str = "" + + def __init__(self, rounds: int = 12, ident: str = "$2b$"): + self.rounds = rounds + self.ident = ident + + def _get_config(self) -> bytes: + config = bcrypt.gensalt( + self.rounds, prefix=self.ident.strip("$").encode("ascii") + ) + self.salt = config.decode("ascii")[len(self.ident) + 3 :] + return config + + def to_string(self) -> str: + return "%s%02d$%s%s" % (self.ident, self.rounds, self.salt, self.checksum) + + def hash(self, key: str) -> str: + validate_secret(key) + config = self._get_config() + hash_ = bcrypt.hashpw(key.encode("utf-8"), config) + if not hash_.startswith(config) or len(hash_) != len(config) + 31: + raise ValueError("bcrypt.hashpw returned an invalid hash") + self.checksum = hash_[-31:].decode("ascii") + return self.to_string() + + def verify(self, key: str, hash: str) -> bool: + validate_secret(key) + + if hash is None: + self.hash(_dummy_secret) + return False + + return bcrypt.checkpw(key.encode("utf-8"), hash.encode("utf-8")) -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +pwd_context = BcryptPasswordHandler() + + +def validate_secret(secret): + """ensure secret has correct type & size""" + if not isinstance(secret, (str, bytes)): + raise TypeError("secret must be a string or bytes") + if len(secret) > MAX_PASSWORD_SIZE: + raise ValueError( + f"secret is too long, maximum length is {MAX_PASSWORD_SIZE} characters" + ) def generate_secret_key(): diff --git a/alchemiscale/tests/unit/test_security.py b/alchemiscale/tests/unit/test_security.py index fca015e3..0f120414 100644 --- a/alchemiscale/tests/unit/test_security.py +++ b/alchemiscale/tests/unit/test_security.py @@ -30,3 +30,10 @@ def test_token_data(secret_key): token_data = auth.get_token_data(token=token, secret_key=secret_key) assert token_data.scopes == ["*-*-*"] + + +def test_bcrypt_password_handler(): + handler = auth.BcryptPasswordHandler() + hash_ = handler.hash("test") + assert handler.verify("test", hash_) + assert not handler.verify("deadbeef", hash_) diff --git a/devtools/conda-envs/alchemiscale-server.yml b/devtools/conda-envs/alchemiscale-server.yml index a175762f..2b764e8c 100644 --- a/devtools/conda-envs/alchemiscale-server.yml +++ b/devtools/conda-envs/alchemiscale-server.yml @@ -29,7 +29,6 @@ dependencies: - uvicorn - gunicorn - python-jose - - passlib - bcrypt - python-multipart - starlette diff --git a/devtools/conda-envs/test.yml b/devtools/conda-envs/test.yml index a23320c2..144051cb 100644 --- a/devtools/conda-envs/test.yml +++ b/devtools/conda-envs/test.yml @@ -17,7 +17,7 @@ dependencies: - monotonic - docker-py # for grolt - # user client printing + # user client printing - rich ## object store @@ -28,7 +28,6 @@ dependencies: - uvicorn - gunicorn - python-jose - - passlib - bcrypt - python-multipart - starlette diff --git a/docs/conf.py b/docs/conf.py index 895c4f15..fdf43e2b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,7 +43,6 @@ "jose", "networkx", "numpy", - "passlib", "py2neo", "pydantic", "starlette",