From 0299d8722e4eb6b329034b70d54d9100b223801e Mon Sep 17 00:00:00 2001 From: immerrr Date: Sun, 11 Jul 2021 10:28:32 +0200 Subject: [PATCH 1/6] Add a way to configure basic auth without storing passwords in plaintext --- fastapi_security/api.py | 21 ++++- fastapi_security/basic.py | 63 ++++++++++++- fastapi_security/gendigest.py | 133 +++++++++++++++++++++++++++ pyproject.toml | 1 + tests/integration/test_basic_auth.py | 40 ++++++++ 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 fastapi_security/gendigest.py diff --git a/fastapi_security/api.py b/fastapi_security/api.py index 0fa9d52..69e7554 100644 --- a/fastapi_security/api.py +++ b/fastapi_security/api.py @@ -5,7 +5,12 @@ from fastapi.security.http import HTTPAuthorizationCredentials from starlette.datastructures import Headers -from .basic import BasicAuthValidator, IterableOfHTTPBasicCredentials +from .basic import ( + BasicAuthValidator, + BasicAuthWithDigestValidator, + IterableOfHTTPBasicCredentials, + IterableOfHTTPBasicCredentialsDigest, +) from .entities import AuthMethod, User, UserAuth, UserInfo from .exceptions import AuthNotConfigured from .oauth2 import Oauth2JwtAccessTokenValidator @@ -26,6 +31,7 @@ class FastAPISecurity: def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermission): self.basic_auth = BasicAuthValidator() + self.basic_auth_with_digest = BasicAuthWithDigestValidator() self.oauth2_jwt = Oauth2JwtAccessTokenValidator() self.oidc_discovery = OpenIdConnectDiscovery() self._permission_overrides: Dict[str, List[str]] = {} @@ -37,6 +43,9 @@ def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermissi def init_basic_auth(self, basic_auth_credentials: IterableOfHTTPBasicCredentials): self.basic_auth.init(basic_auth_credentials) + def init_basic_auth_with_digest(self, salt: str, basic_auth_with_digest_credentials: IterableOfHTTPBasicCredentialsDigest): + self.basic_auth_with_digest.init(salt, basic_auth_with_digest_credentials) + def init_oauth2_through_oidc( self, oidc_discovery_url: str, *, audiences: Iterable[str] = None ): @@ -169,7 +178,7 @@ async def dependency( ) -> Optional[UserAuth]: oidc_configured = self.oidc_discovery.is_configured() oauth2_configured = self.oauth2_jwt.is_configured() - basic_auth_configured = self.basic_auth.is_configured() + basic_auth_configured = self.basic_auth.is_configured() or self.basic_auth_with_digest.is_configured() if not any([oidc_configured, oauth2_configured, basic_auth_configured]): raise AuthNotConfigured() @@ -185,8 +194,12 @@ async def dependency( return self._maybe_override_permissions( UserAuth.from_jwt_access_token(access_token) ) - elif http_credentials is not None and self.basic_auth.is_configured(): - if self.basic_auth.validate(http_credentials): + elif http_credentials is not None: + is_valid = ( + self.basic_auth.validate(http_credentials) or + self.basic_auth_with_digest.validate(http_credentials) + ) + if is_valid: return self._maybe_override_permissions( UserAuth( subject=http_credentials.username, diff --git a/fastapi_security/basic.py b/fastapi_security/basic.py index b4e8f32..adadc46 100644 --- a/fastapi_security/basic.py +++ b/fastapi_security/basic.py @@ -1,12 +1,29 @@ import secrets +from base64 import urlsafe_b64encode from typing import Dict, Iterable, List, Union +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.hashes import SHA512, Hash + from fastapi.security.http import HTTPBasicCredentials -__all__ = ("HTTPBasicCredentials",) +__all__ = ("HTTPBasicCredentials", "generate_digest") + + +from pydantic import BaseModel + + +class HTTPBasicCredentialsDigest(BaseModel): + username: str + digest: str + IterableOfHTTPBasicCredentials = Iterable[Union[HTTPBasicCredentials, Dict]] +IterableOfHTTPBasicCredentialsDigest = Iterable[ + Union[HTTPBasicCredentialsDigest, Dict] +] + class BasicAuthValidator: def __init__(self): @@ -36,3 +53,47 @@ def _make_credentials( c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c) for c in credentials ] + + +class BasicAuthWithDigestValidator: + def __init__(self): + self._salt = None + self._credentials = [] + + def init(self, salt: str, credentials: IterableOfHTTPBasicCredentialsDigest): + self._salt = salt + self._credentials = self._make_credentials(credentials) + + def is_configured(self) -> bool: + return self._salt and len(self._credentials) > 0 + + def validate(self, credentials: HTTPBasicCredentials) -> bool: + if not self.is_configured(): + return False + return any( + ( + secrets.compare_digest(c.username, credentials.username) + and c.digest == self.generate_digest(self._salt, credentials.password) + ) + for c in self._credentials + ) + + def generate_digest(self, secret: str): + if not self._salt: + raise ValueError('BasicAuthWithDigestValidator: cannot generate digest, salt is empty') + return generate_digest(self._salt, secret) + + def _make_credentials( + self, credentials: IterableOfHTTPBasicCredentialsDigest + ) -> List[HTTPBasicCredentialsDigest]: + return [ + c if isinstance(c, HTTPBasicCredentialsDigest) else HTTPBasicCredentialsDigest(**c) + for c in credentials + ] + + +def generate_digest(salt: str, secret: str): + hash_obj = Hash(algorithm=SHA512(), backend=default_backend()) + hash_obj.update((salt + secret).encode('latin1')) + result = hash_obj.finalize() + return urlsafe_b64encode(result).decode('latin1') diff --git a/fastapi_security/gendigest.py b/fastapi_security/gendigest.py new file mode 100644 index 0000000..26b8972 --- /dev/null +++ b/fastapi_security/gendigest.py @@ -0,0 +1,133 @@ +"""Generate digest for basic_auth_with_digest credentials. + +Takes an instance of FastAPISecurity that has basic_auth_with_digest configured +(even if with empty credential list), prompts for password and generates a +digest that can be appended to that instance's list of credentials. + +Example: + +$ python -m fastapi_security.gendigest fastapi_security.gendigest:obj +Password: +Confirm password: +0jFS-cNapwQf_lpyULF7_hEelbl_zreNVHbxqKwKIFmPRQ09bYTEDQLrr_UEWZc9fdYFiU5F3il3rovJQ_UEpg== + +""" + +import argparse +import importlib +import sys +import textwrap +from getpass import getpass +from types import ModuleType +from typing import Union + +from fastapi_security import FastAPISecurity + + +def _wrap_paragraphs(s): + paragraphs = s.strip().split('\n\n') + wrapped_paragraphs = [ + '\n'.join(textwrap.wrap(paragraph)) for paragraph in paragraphs + ] + return '\n\n'.join(wrapped_paragraphs) + + +def import_from_string(import_str: Union[ModuleType, str]) -> ModuleType: + """import_from_string: part of uvicorn codebase + + Copyright © 2017-present, Encode OSS Ltd. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + """ + if not isinstance(import_str, str): + return import_str + + module_str, _, attrs_str = import_str.partition(":") + if not module_str or not attrs_str: + message = ( + 'Import string "{import_str}" must be in format ":".' + ) + raise ValueError(message.format(import_str=import_str)) + + try: + module = importlib.import_module(module_str) + except ImportError as exc: + if exc.name != module_str: + raise exc from None + message = 'Could not import module "{module_str}".' + raise ValueError(message.format(module_str=module_str)) + + instance = module + try: + for attr_str in attrs_str.split("."): + instance = getattr(instance, attr_str) + except AttributeError: + message = 'Attribute "{attrs_str}" not found in module "{module_str}".' + raise ValueError( + message.format(attrs_str=attrs_str, module_str=module_str) + ) + + return instance + + +parser = argparse.ArgumentParser( + description=_wrap_paragraphs(__doc__), + formatter_class=argparse.RawDescriptionHelpFormatter, +) +parser.add_argument('fastapi_security_obj') + + +obj = FastAPISecurity() +obj.init_basic_auth_with_digest('salt123', []) + + +def main(): + args = parser.parse_args() + + fastapi_security_obj = import_from_string(args.fastapi_security_obj) + if callable(fastapi_security_obj): + instance = fastapi_security_obj() + elif isinstance(fastapi_security_obj, FastAPISecurity): + instance = fastapi_security_obj + else: + print("Cannot generate digest: ", args.fastapi_security_obj, + "must point to a FastAPISecurity object or a function returning one", + file=sys.error) + sys.exit(1) + + password = getpass(prompt='Password: ') + password_confirmation = getpass(prompt='Confirm password: ') + + if password != password_confirmation: + print("Cannot generate digest: passwords don't match", file=sys.stderr) + sys.exit(1) + + print(instance.basic_auth_with_digest.generate_digest(password)) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index 010fae7..a1c7bfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ aiohttp = "^3" fastapi = "^0" pydantic = "^1" PyJWT = {version = "^2", extras = ["crypto"]} +cryptography = "^3.4.7" [tool.poetry.dev-dependencies] aioresponses = "^0.7.2" diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 1b69c7a..128c460 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -2,6 +2,7 @@ from fastapi_security import FastAPISecurity, HTTPBasicCredentials, User from fastapi_security.basic import BasicAuthValidator +from fastapi_security.basic import generate_digest from ..helpers.jwks import dummy_audience, dummy_jwks_uri @@ -65,3 +66,42 @@ def get_products(user: User = Depends(security.authenticated_user_or_401)): resp = client.get("/", auth=("user", "pass")) assert resp.status_code == 200 + + +def test_that_basic_auth_with_digest_rejects_incorrect_credentials(app, client): + security = FastAPISecurity() + + @app.get("/") + def get_products(user: User = Depends(security.authenticated_user_or_401)): + return [] + + pass_digest = generate_digest('salt123', 'pass') + credentials = [{"username": "user", "digest": pass_digest}] + security.init_basic_auth_with_digest('salt123', credentials) + + resp = client.get("/") + assert resp.status_code == 401 + + resp = client.get("/", auth=("user", "")) + assert resp.status_code == 401 + + resp = client.get("/", auth=("", "pass")) + assert resp.status_code == 401 + + resp = client.get("/", auth=("abc", "123")) + assert resp.status_code == 401 + + +def test_that_basic_auth_with_digest_accepts_correct_credentials(app, client): + security = FastAPISecurity() + + @app.get("/") + def get_products(user: User = Depends(security.authenticated_user_or_401)): + return [] + + pass_digest = generate_digest('salt123', 'pass') + credentials = [{"username": "user", "digest": pass_digest}] + security.init_basic_auth_with_digest('salt123', credentials) + + resp = client.get("/", auth=("user", "pass")) + assert resp.status_code == 200 From c3b1bf2195d81d856f964d6a295117cc9812182b Mon Sep 17 00:00:00 2001 From: immerrr Date: Sat, 4 Dec 2021 08:50:25 +0100 Subject: [PATCH 2/6] Reuse HTTPBasicCredentials to configure digest-based validator --- fastapi_security/api.py | 30 +++++++------- fastapi_security/basic.py | 58 ++++++++++------------------ tests/integration/test_basic_auth.py | 36 ++++++++++++----- 3 files changed, 64 insertions(+), 60 deletions(-) diff --git a/fastapi_security/api.py b/fastapi_security/api.py index 69e7554..97f250a 100644 --- a/fastapi_security/api.py +++ b/fastapi_security/api.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, Dict, Iterable, List, Optional, Type +from typing import Callable, Dict, Iterable, List, Optional, Type, Union from fastapi import Depends, HTTPException from fastapi.security.http import HTTPAuthorizationCredentials @@ -9,7 +9,6 @@ BasicAuthValidator, BasicAuthWithDigestValidator, IterableOfHTTPBasicCredentials, - IterableOfHTTPBasicCredentialsDigest, ) from .entities import AuthMethod, User, UserAuth, UserInfo from .exceptions import AuthNotConfigured @@ -30,8 +29,8 @@ class FastAPISecurity: """ def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermission): + self.basic_auth: Union[BasicAuthValidator, BasicAuthWithDigestValidator] self.basic_auth = BasicAuthValidator() - self.basic_auth_with_digest = BasicAuthWithDigestValidator() self.oauth2_jwt = Oauth2JwtAccessTokenValidator() self.oidc_discovery = OpenIdConnectDiscovery() self._permission_overrides: Dict[str, List[str]] = {} @@ -41,10 +40,19 @@ def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermissi self._oauth2_audiences: List[str] = [] def init_basic_auth(self, basic_auth_credentials: IterableOfHTTPBasicCredentials): - self.basic_auth.init(basic_auth_credentials) - - def init_basic_auth_with_digest(self, salt: str, basic_auth_with_digest_credentials: IterableOfHTTPBasicCredentialsDigest): - self.basic_auth_with_digest.init(salt, basic_auth_with_digest_credentials) + new_basic_auth = BasicAuthValidator() + new_basic_auth.init(basic_auth_credentials) + self.basic_auth = new_basic_auth + + def init_basic_auth_with_digest( + self, + basic_auth_with_digest_credentials: IterableOfHTTPBasicCredentials, + *, + salt: str, + ): + new_basic_auth = BasicAuthWithDigestValidator() + new_basic_auth.init(basic_auth_with_digest_credentials, salt=salt) + self.basic_auth = new_basic_auth def init_oauth2_through_oidc( self, oidc_discovery_url: str, *, audiences: Iterable[str] = None @@ -178,7 +186,7 @@ async def dependency( ) -> Optional[UserAuth]: oidc_configured = self.oidc_discovery.is_configured() oauth2_configured = self.oauth2_jwt.is_configured() - basic_auth_configured = self.basic_auth.is_configured() or self.basic_auth_with_digest.is_configured() + basic_auth_configured = self.basic_auth.is_configured() if not any([oidc_configured, oauth2_configured, basic_auth_configured]): raise AuthNotConfigured() @@ -195,11 +203,7 @@ async def dependency( UserAuth.from_jwt_access_token(access_token) ) elif http_credentials is not None: - is_valid = ( - self.basic_auth.validate(http_credentials) or - self.basic_auth_with_digest.validate(http_credentials) - ) - if is_valid: + if self.basic_auth.validate(http_credentials): return self._maybe_override_permissions( UserAuth( subject=http_credentials.username, diff --git a/fastapi_security/basic.py b/fastapi_security/basic.py index adadc46..690801b 100644 --- a/fastapi_security/basic.py +++ b/fastapi_security/basic.py @@ -4,33 +4,20 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.hashes import SHA512, Hash - from fastapi.security.http import HTTPBasicCredentials __all__ = ("HTTPBasicCredentials", "generate_digest") -from pydantic import BaseModel - - -class HTTPBasicCredentialsDigest(BaseModel): - username: str - digest: str - - IterableOfHTTPBasicCredentials = Iterable[Union[HTTPBasicCredentials, Dict]] -IterableOfHTTPBasicCredentialsDigest = Iterable[ - Union[HTTPBasicCredentialsDigest, Dict] -] - class BasicAuthValidator: def __init__(self): self._credentials = [] def init(self, credentials: IterableOfHTTPBasicCredentials): - self._credentials = self._make_credentials(credentials) + self._credentials = _make_credentials(credentials) def is_configured(self) -> bool: return len(self._credentials) > 0 @@ -46,23 +33,15 @@ def validate(self, credentials: HTTPBasicCredentials) -> bool: for c in self._credentials ) - def _make_credentials( - self, credentials: IterableOfHTTPBasicCredentials - ) -> List[HTTPBasicCredentials]: - return [ - c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c) - for c in credentials - ] - class BasicAuthWithDigestValidator: def __init__(self): - self._salt = None self._credentials = [] + self._salt = None - def init(self, salt: str, credentials: IterableOfHTTPBasicCredentialsDigest): + def init(self, credentials: IterableOfHTTPBasicCredentials, *, salt: str): + self._credentials = _make_credentials(credentials) self._salt = salt - self._credentials = self._make_credentials(credentials) def is_configured(self) -> bool: return self._salt and len(self._credentials) > 0 @@ -73,27 +52,30 @@ def validate(self, credentials: HTTPBasicCredentials) -> bool: return any( ( secrets.compare_digest(c.username, credentials.username) - and c.digest == self.generate_digest(self._salt, credentials.password) + and c.password == self.generate_digest(credentials.password) ) for c in self._credentials ) def generate_digest(self, secret: str): if not self._salt: - raise ValueError('BasicAuthWithDigestValidator: cannot generate digest, salt is empty') - return generate_digest(self._salt, secret) + raise ValueError( + "BasicAuthWithDigestValidator: cannot generate digest, salt is empty" + ) + return generate_digest(secret, salt=self._salt) + - def _make_credentials( - self, credentials: IterableOfHTTPBasicCredentialsDigest - ) -> List[HTTPBasicCredentialsDigest]: - return [ - c if isinstance(c, HTTPBasicCredentialsDigest) else HTTPBasicCredentialsDigest(**c) - for c in credentials - ] +def _make_credentials( + credentials: IterableOfHTTPBasicCredentials, +) -> List[HTTPBasicCredentials]: + return [ + c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c) + for c in credentials + ] -def generate_digest(salt: str, secret: str): +def generate_digest(secret: str, *, salt: str): hash_obj = Hash(algorithm=SHA512(), backend=default_backend()) - hash_obj.update((salt + secret).encode('latin1')) + hash_obj.update((salt + secret).encode("latin1")) result = hash_obj.finalize() - return urlsafe_b64encode(result).decode('latin1') + return urlsafe_b64encode(result).decode("latin1") diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 128c460..6013af3 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -1,8 +1,7 @@ from fastapi import Depends from fastapi_security import FastAPISecurity, HTTPBasicCredentials, User -from fastapi_security.basic import BasicAuthValidator -from fastapi_security.basic import generate_digest +from fastapi_security.basic import BasicAuthValidator, generate_digest from ..helpers.jwks import dummy_audience, dummy_jwks_uri @@ -68,16 +67,18 @@ def get_products(user: User = Depends(security.authenticated_user_or_401)): assert resp.status_code == 200 -def test_that_basic_auth_with_digest_rejects_incorrect_credentials(app, client): +def test_that_basic_auth_with_digest_rejects_credentials_with_wrong_user_or_password( + app, client +): security = FastAPISecurity() @app.get("/") def get_products(user: User = Depends(security.authenticated_user_or_401)): return [] - pass_digest = generate_digest('salt123', 'pass') - credentials = [{"username": "user", "digest": pass_digest}] - security.init_basic_auth_with_digest('salt123', credentials) + pass_digest = generate_digest("pass", salt="salt123") + credentials = [{"username": "user", "password": pass_digest}] + security.init_basic_auth_with_digest(credentials, salt="salt123") resp = client.get("/") assert resp.status_code == 401 @@ -92,6 +93,23 @@ def get_products(user: User = Depends(security.authenticated_user_or_401)): assert resp.status_code == 401 +def test_that_basic_auth_with_digest_rejects_credentials_when_salt_does_not_match( + app, client +): + security = FastAPISecurity() + + @app.get("/") + def get_products(user: User = Depends(security.authenticated_user_or_401)): + return [] + + pass_digest = generate_digest("pass", salt="salt123") + credentials = [{"username": "user", "password": pass_digest}] + security.init_basic_auth_with_digest(credentials, salt="salt456") + + resp = client.get("/", auth=("user", "pass")) + assert resp.status_code == 401 + + def test_that_basic_auth_with_digest_accepts_correct_credentials(app, client): security = FastAPISecurity() @@ -99,9 +117,9 @@ def test_that_basic_auth_with_digest_accepts_correct_credentials(app, client): def get_products(user: User = Depends(security.authenticated_user_or_401)): return [] - pass_digest = generate_digest('salt123', 'pass') - credentials = [{"username": "user", "digest": pass_digest}] - security.init_basic_auth_with_digest('salt123', credentials) + pass_digest = generate_digest("pass", salt="salt123") + credentials = [{"username": "user", "password": pass_digest}] + security.init_basic_auth_with_digest(credentials, salt="salt123") resp = client.get("/", auth=("user", "pass")) assert resp.status_code == 200 From 6b791da2c95528bf5812e898fb3f485627fb42a2 Mon Sep 17 00:00:00 2001 From: immerrr Date: Sat, 4 Dec 2021 10:38:44 +0100 Subject: [PATCH 3/6] Rework gendigest script into fastapi_security.cli --- fastapi_security/cli.py | 93 ++++++++++++++++++++++++ fastapi_security/gendigest.py | 133 ---------------------------------- pyproject.toml | 3 + tests/integration/test_cli.py | 55 ++++++++++++++ 4 files changed, 151 insertions(+), 133 deletions(-) create mode 100644 fastapi_security/cli.py delete mode 100644 fastapi_security/gendigest.py create mode 100644 tests/integration/test_cli.py diff --git a/fastapi_security/cli.py b/fastapi_security/cli.py new file mode 100644 index 0000000..8aa4fba --- /dev/null +++ b/fastapi_security/cli.py @@ -0,0 +1,93 @@ +"""fastapi_security command-line interface""" + +import argparse +import sys +import textwrap +from getpass import getpass +from typing import Optional, Sequence, Text + +from fastapi_security.basic import generate_digest + + +def _wrap_paragraphs(s): + paragraphs = s.strip().split("\n\n") + wrapped_paragraphs = [ + "\n".join(textwrap.wrap(paragraph)) for paragraph in paragraphs + ] + return "\n\n".join(wrapped_paragraphs) + + +main_parser = argparse.ArgumentParser( + description=_wrap_paragraphs(__doc__), + formatter_class=argparse.RawDescriptionHelpFormatter, +) +subcommand_parsers = main_parser.add_subparsers( + help="Specify a sub-command", + dest="subcommand", + required=True, +) + +gendigest_description = """ +Generate digest for basic_auth_with_digest credentials. + +Example: + +$ fastapi-security gendigest --salt=very-strong-salt +Password: +Confirm password: + +Here is your digest: +0jFS-cNapwQf_lpyULF7_hEelbl_zreNVHbxqKwKIFmPRQ09bYTEDQLrr_UEWZc9fdYFiU5F3il3rovJQ_UEpg== + +$ cat fastapi_security_conf.py +from fastapi_security import FastAPISecurity + +security = FastAPISecurity() +security.init_basic_auth_with_digest( + [ + {'user': 'me', 'password': '0jFS-cNapwQf_lpyULF7_hEelbl_zreNVHbxqKwKIFmPRQ09bYTEDQLrr_UEWZc9fdYFiU5F3il3rovJQ_UEpg=='} + ], + salt='very-strong-salt', +) +""" + +gendigest_parser = subcommand_parsers.add_parser( + "gendigest", + description=gendigest_description, + formatter_class=argparse.RawDescriptionHelpFormatter, +) +gendigest_parser.add_argument( + "--salt", + help="Salt value used in fastapi_security configuration.", + required=True, +) + + +def gendigest(parsed_args): + # if not parsed_args.salt: + # print("Cannot generate digest: --salt must be non-empty", + # file=sys.stderr) + # sys.exit(1) + + password = getpass(prompt="Password: ") + password_confirmation = getpass(prompt="Confirm password: ") + + if password != password_confirmation: + print("Cannot generate digest: passwords don't match", file=sys.stderr) + sys.exit(1) + + print("\nHere is your digest:", file=sys.stderr) + print(generate_digest(password, salt=parsed_args.salt)) + + +def main(args: Optional[Sequence[Text]] = None): + parsed_args = main_parser.parse_args(args) + if parsed_args.subcommand == "gendigest": + return gendigest(parsed_args) + + main_parser.print_usage(file=sys.stderr) + sys.exit(2) # invalid usage: missing subcommand + + +if __name__ == "__main__": + main() diff --git a/fastapi_security/gendigest.py b/fastapi_security/gendigest.py deleted file mode 100644 index 26b8972..0000000 --- a/fastapi_security/gendigest.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Generate digest for basic_auth_with_digest credentials. - -Takes an instance of FastAPISecurity that has basic_auth_with_digest configured -(even if with empty credential list), prompts for password and generates a -digest that can be appended to that instance's list of credentials. - -Example: - -$ python -m fastapi_security.gendigest fastapi_security.gendigest:obj -Password: -Confirm password: -0jFS-cNapwQf_lpyULF7_hEelbl_zreNVHbxqKwKIFmPRQ09bYTEDQLrr_UEWZc9fdYFiU5F3il3rovJQ_UEpg== - -""" - -import argparse -import importlib -import sys -import textwrap -from getpass import getpass -from types import ModuleType -from typing import Union - -from fastapi_security import FastAPISecurity - - -def _wrap_paragraphs(s): - paragraphs = s.strip().split('\n\n') - wrapped_paragraphs = [ - '\n'.join(textwrap.wrap(paragraph)) for paragraph in paragraphs - ] - return '\n\n'.join(wrapped_paragraphs) - - -def import_from_string(import_str: Union[ModuleType, str]) -> ModuleType: - """import_from_string: part of uvicorn codebase - - Copyright © 2017-present, Encode OSS Ltd. All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. - """ - if not isinstance(import_str, str): - return import_str - - module_str, _, attrs_str = import_str.partition(":") - if not module_str or not attrs_str: - message = ( - 'Import string "{import_str}" must be in format ":".' - ) - raise ValueError(message.format(import_str=import_str)) - - try: - module = importlib.import_module(module_str) - except ImportError as exc: - if exc.name != module_str: - raise exc from None - message = 'Could not import module "{module_str}".' - raise ValueError(message.format(module_str=module_str)) - - instance = module - try: - for attr_str in attrs_str.split("."): - instance = getattr(instance, attr_str) - except AttributeError: - message = 'Attribute "{attrs_str}" not found in module "{module_str}".' - raise ValueError( - message.format(attrs_str=attrs_str, module_str=module_str) - ) - - return instance - - -parser = argparse.ArgumentParser( - description=_wrap_paragraphs(__doc__), - formatter_class=argparse.RawDescriptionHelpFormatter, -) -parser.add_argument('fastapi_security_obj') - - -obj = FastAPISecurity() -obj.init_basic_auth_with_digest('salt123', []) - - -def main(): - args = parser.parse_args() - - fastapi_security_obj = import_from_string(args.fastapi_security_obj) - if callable(fastapi_security_obj): - instance = fastapi_security_obj() - elif isinstance(fastapi_security_obj, FastAPISecurity): - instance = fastapi_security_obj - else: - print("Cannot generate digest: ", args.fastapi_security_obj, - "must point to a FastAPISecurity object or a function returning one", - file=sys.error) - sys.exit(1) - - password = getpass(prompt='Password: ') - password_confirmation = getpass(prompt='Confirm password: ') - - if password != password_confirmation: - print("Cannot generate digest: passwords don't match", file=sys.stderr) - sys.exit(1) - - print(instance.basic_auth_with_digest.generate_digest(password)) - - -if __name__ == '__main__': - main() diff --git a/pyproject.toml b/pyproject.toml index a1c7bfb..4ec77ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ classifiers = [ "Typing :: Typed", ] +[tool.poetry.scripts] +fastapi-security = 'fastapi_security.cli:main' + [tool.poetry.dependencies] python = "^3.6" aiohttp = "^3" diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..d944122 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,55 @@ +import subprocess +from unittest import mock + +from fastapi_security import cli + + +def test_usage_output_without_params(): + result = subprocess.run(["fastapi-security"], capture_output=True) + assert result.returncode == 2 + assert result.stdout.decode().splitlines() == [] + assert result.stderr.decode().splitlines() == [ + "usage: fastapi-security [-h] {gendigest} ...", + "fastapi-security: error: the following arguments are required: subcommand", + ] + + +def test_usage_with_help_param(): + result = subprocess.run(["fastapi-security", "-h"], capture_output=True) + assert result.returncode == 0 + assert result.stdout.decode().splitlines() == [ + "usage: fastapi-security [-h] {gendigest} ...", + "", + "fastapi_security command-line interface", + "", + "positional arguments:", + " {gendigest} Specify a sub-command", + "", + "optional arguments:", + " -h, --help show this help message and exit", + ] + assert result.stderr.decode().splitlines() == [] + + +def test_gendigest_without_params(): + result = subprocess.run(["fastapi-security", "gendigest"], capture_output=True) + assert result.returncode == 2 + assert result.stdout.decode().splitlines() == [] + assert result.stderr.decode().splitlines() == [ + "usage: fastapi-security gendigest [-h] --salt SALT", + "fastapi-security gendigest: error: the following arguments are required: --salt", + ] + + +def test_gendigest_smoke_test(capsys, monkeypatch): + # gendigest smoke test is performed not in a subprocess, because getpass + # uses /dev/tty instead of stdin/stdout for security reasons, and it is + # much more tricky to intercept it, so instead go for a simple monkeypatch. + monkeypatch.setattr(cli, "getpass", mock.Mock(return_value="hello")) + cli.main(["gendigest", "--salt=very-strong-salt"]) + captured = capsys.readouterr() + assert ( + captured.out + == "xRPfDaQHwpcXlzfWeR_uqOBTytcjEAUMv98SDnbHmpajmT_AxeJTHX6FyeM8H1T4otOe81PMWAOqAD5_tO4gYg==\n" + ) + assert captured.err == "\nHere is your digest:\n" From 5dab1ec16b7dff50cd1737524a1fd4fd57d54a26 Mon Sep 17 00:00:00 2001 From: immerrr Date: Wed, 5 Jan 2022 13:33:24 +0100 Subject: [PATCH 4/6] cli: add_subparsers: don't use "required" kwarg to support Python <=3.6 --- fastapi_security/cli.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/fastapi_security/cli.py b/fastapi_security/cli.py index 8aa4fba..f0814be 100644 --- a/fastapi_security/cli.py +++ b/fastapi_security/cli.py @@ -24,7 +24,10 @@ def _wrap_paragraphs(s): subcommand_parsers = main_parser.add_subparsers( help="Specify a sub-command", dest="subcommand", - required=True, + # This would remove the need to manually print an error message if + # subcommand is not specified, but it is only available for Python 3.7+ + # + # required=True, ) gendigest_description = """ @@ -86,6 +89,14 @@ def main(args: Optional[Sequence[Text]] = None): return gendigest(parsed_args) main_parser.print_usage(file=sys.stderr) + if not parsed_args.subcommand: + # Error message mimicking that of Python 3.7+ where add_subcommand(...) + # function has "required=True" kwarg. + required_subcommand_msg = ( + "fastapi-security: error:" + " the following arguments are required: subcommand" + ) + print(required_subcommand_msg, file=sys.stderr) sys.exit(2) # invalid usage: missing subcommand From e11d56f3431d260a171faa076840e1df7eb866f5 Mon Sep 17 00:00:00 2001 From: immerrr Date: Wed, 5 Jan 2022 14:51:12 +0100 Subject: [PATCH 5/6] CI: disable python 3.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa838a8..832c092 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, 3.8, 3.7, 3.6] + python-version: [3.9, 3.8, 3.7] os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v2 From b38bb79bfe0c97c7bbdf9b882ada74e02543cf6a Mon Sep 17 00:00:00 2001 From: immerrr Date: Wed, 5 Jan 2022 14:51:53 +0100 Subject: [PATCH 6/6] Revert "cli: add_subparsers: don't use "required" kwarg to support Python <=3.6" This reverts commit 5dab1ec16b7dff50cd1737524a1fd4fd57d54a26. --- fastapi_security/cli.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/fastapi_security/cli.py b/fastapi_security/cli.py index f0814be..8aa4fba 100644 --- a/fastapi_security/cli.py +++ b/fastapi_security/cli.py @@ -24,10 +24,7 @@ def _wrap_paragraphs(s): subcommand_parsers = main_parser.add_subparsers( help="Specify a sub-command", dest="subcommand", - # This would remove the need to manually print an error message if - # subcommand is not specified, but it is only available for Python 3.7+ - # - # required=True, + required=True, ) gendigest_description = """ @@ -89,14 +86,6 @@ def main(args: Optional[Sequence[Text]] = None): return gendigest(parsed_args) main_parser.print_usage(file=sys.stderr) - if not parsed_args.subcommand: - # Error message mimicking that of Python 3.7+ where add_subcommand(...) - # function has "required=True" kwarg. - required_subcommand_msg = ( - "fastapi-security: error:" - " the following arguments are required: subcommand" - ) - print(required_subcommand_msg, file=sys.stderr) sys.exit(2) # invalid usage: missing subcommand