Skip to content

Commit

Permalink
v0.3.0 - new features, breaking API changes and 100% test coverage
Browse files Browse the repository at this point in the history
### Added

- OAuth2 and OIDC can now be enabled by just passing an OIDC discovery URL to `FastAPISecurity.init_oauth2_through_oidc`
- Cached data is now used for JWKS and OIDC endpoints in case the "refresh requests" fail.

### Changed
- `UserPermission` objects are now created via `FastAPISecurity.user_permission`.
- `FastAPISecurity.init` was split into three distinct methods: `.init_basic_auth`, `.init_oauth2_through_oidc` and `.init_oauth2_through_jwks`.
- Broke out the `permission_overrides` argument from the old `.init` method and added a distinct method for adding new overrides `add_permission_overrides`. This method can be called multiple times.
- The dependency `FastAPISecurity.has_permission` and `FastAPISecurity.user_with_permissions` has been replaced by `FastAPISecurity.user_holding`. API is the same (takes a variable number of UserPermission arguments, i.e. compatible with both).

### Removed
- Remove `app` argument to the `FastAPISecurity.init...` methods (it wasn't used before)
- The global permissions registry has been removed. Now there should be no global mutable state left.
  • Loading branch information
jacobsvante committed Mar 26, 2021
1 parent cb134b9 commit b71b841
Show file tree
Hide file tree
Showing 36 changed files with 1,280 additions and 406 deletions.
26 changes: 26 additions & 0 deletions CHANGELOG.md
@@ -0,0 +1,26 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- Nothing

## [0.3.0](https://github.com/jmagnusson/fastapi-security/compare/v0.2.0...v0.3.0) - 2021-03-26

### Added

- OAuth2 and OIDC can now be enabled by just passing an OIDC discovery URL to `FastAPISecurity.init_oauth2_through_oidc`
- Cached data is now used for JWKS and OIDC endpoints in case the "refresh requests" fail.

### Changed
- `UserPermission` objects are now created via `FastAPISecurity.user_permission`.
- `FastAPISecurity.init` was split into three distinct methods: `.init_basic_auth`, `.init_oauth2_through_oidc` and `.init_oauth2_through_jwks`.
- Broke out the `permission_overrides` argument from the old `.init` method and added a distinct method for adding new overrides `add_permission_overrides`. This method can be called multiple times.
- The dependency `FastAPISecurity.has_permission` and `FastAPISecurity.user_with_permissions` has been replaced by `FastAPISecurity.user_holding`. API is the same (takes a variable number of UserPermission arguments, i.e. compatible with both).

### Removed
- Remove `app` argument to the `FastAPISecurity.init...` methods (it wasn't used before)
- The global permissions registry has been removed. Now there should be no global mutable state left.
8 changes: 5 additions & 3 deletions examples/app1/README.md
@@ -1,12 +1,14 @@
# FastAPI Security Example App
# FastAPI-Security Example App

To try out:

```bash
pip install fastapi-security uvicorn
export OIDC_DISCOVERY_URL='https://my-auth0-tenant.eu.auth0.com/.well-known/openid-configuration'
export OAUTH2_AUDIENCES='["my-audience"]'
export BASIC_AUTH_CREDENTIALS='[{"username": "user1", "password": "test"}]'
export AUTH_JWKS_URL='https://my-auth0-tenant.eu.auth0.com/.well-known/jwks.json'
export AUTH_AUDIENCES='["my-audience"]'
export PERMISSION_OVERRIDES='{"user1": ["products:create"]}'
uvicorn app1:app
```

You would need to replace the `my-auth0-tenant.eu.auth0.com` part to make it work.
32 changes: 20 additions & 12 deletions examples/app1/app.py
Expand Up @@ -3,7 +3,7 @@

from fastapi import Depends, FastAPI

from fastapi_security import FastAPISecurity, User, UserPermission
from fastapi_security import FastAPISecurity, User

from . import db
from .models import Product
Expand All @@ -15,18 +15,26 @@

security = FastAPISecurity()

security.init(
app,
basic_auth_credentials=settings.basic_auth_credentials,
jwks_url=settings.oauth2_jwks_url,
audiences=settings.oauth2_audiences,
oidc_discovery_url=settings.oidc_discovery_url,
permission_overrides=settings.permission_overrides,
)
if settings.basic_auth_credentials:
security.init_basic_auth(settings.basic_auth_credentials)

if settings.oidc_discovery_url:
security.init_oauth2_through_oidc(
settings.oidc_discovery_url,
audiences=settings.oauth2_audiences,
)
elif settings.oauth2_jwks_url:
security.init_oauth2_through_jwks(
settings.oauth2_jwks_url,
audiences=settings.oauth2_audiences,
)

security.add_permission_overrides(settings.permission_overrides or {})


logger = logging.getLogger(__name__)

create_product_perm = UserPermission("products:create")
create_product_perm = security.user_permission("products:create")


@app.get("/users/me")
Expand All @@ -41,10 +49,10 @@ def get_user_permissions(user: User = Depends(security.authenticated_user_or_401
return user.permissions


@app.post("/products", response_model=Product)
@app.post("/products", response_model=Product, status_code=201)
async def create_product(
product: Product,
user: User = Depends(security.user_with_permissions(create_product_perm)),
user: User = Depends(security.user_holding(create_product_perm)),
):
"""Create product
Expand Down
14 changes: 7 additions & 7 deletions examples/app1/settings.py
@@ -1,20 +1,20 @@
from functools import lru_cache
from typing import Dict, List, Optional
from typing import List, Optional

from fastapi.security import HTTPBasicCredentials
from pydantic import BaseSettings

from fastapi_security import HTTPBasicCredentials, PermissionOverrides

__all__ = ("get_settings",)


class _Settings(BaseSettings):
oauth2_jwks_url: Optional[
str
] = None # TODO: This could be retrieved from OIDC discovery URL
# NOTE: You only need to supply `oidc_discovery_url` (preferred) OR `oauth2_jwks_url`
oidc_discovery_url: Optional[str] = None
oauth2_jwks_url: Optional[str] = None
oauth2_audiences: Optional[List[str]] = None
basic_auth_credentials: Optional[List[HTTPBasicCredentials]] = None
oidc_discovery_url: Optional[str] = None
permission_overrides: Optional[Dict[str, List[str]]] = None
permission_overrides: PermissionOverrides = {}


@lru_cache()
Expand Down
1 change: 0 additions & 1 deletion fastapi_security/__init__.py
Expand Up @@ -4,5 +4,4 @@
from .oauth2 import * # noqa
from .oidc import * # noqa
from .permissions import * # noqa
from .registry import * # noqa
from .schemes import * # noqa
130 changes: 79 additions & 51 deletions fastapi_security/api.py
@@ -1,17 +1,15 @@
import logging
from typing import Callable, Dict, List, Optional
from typing import Callable, Iterable, List, Optional, Type

from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPBasicCredentials
from fastapi import Depends, HTTPException
from fastapi.security.http import HTTPAuthorizationCredentials

from . import registry
from .basic import BasicAuthValidator
from .basic import BasicAuthValidator, IterableOfHTTPBasicCredentials
from .entities import AuthMethod, User, UserAuth, UserInfo
from .exceptions import AuthNotConfigured
from .oauth2 import Oauth2JwtAccessTokenValidator
from .oidc import OpenIdConnectDiscovery
from .permissions import UserPermission
from .permissions import PermissionOverrides, UserPermission
from .schemes import http_basic_scheme, jwt_bearer_scheme

logger = logging.getLogger(__name__)
Expand All @@ -25,34 +23,60 @@ class FastAPISecurity:
Must be initialized after object creation via the `init()` method.
"""

def __init__(self):
def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermission):
self.basic_auth = BasicAuthValidator()
self.oauth2_jwt = Oauth2JwtAccessTokenValidator()
self.oidc_discovery = OpenIdConnectDiscovery()
self._permission_overrides = None

def init(
self,
app: FastAPI,
basic_auth_credentials: List[HTTPBasicCredentials] = None,
permission_overrides: Dict[str, List[str]] = None,
jwks_url: str = None,
audiences: List[str] = None,
oidc_discovery_url: str = None,
self._permission_overrides: PermissionOverrides = {}
self._user_permission_class = user_permission_class
self._all_permissions: List[UserPermission] = []
self._oauth2_init_through_oidc = False
self._oauth2_audiences: List[str] = []

def init_basic_auth(self, basic_auth_credentials: IterableOfHTTPBasicCredentials):
self.basic_auth.init(basic_auth_credentials)

def init_oauth2_through_oidc(
self, oidc_discovery_url: str, *, audiences: Iterable[str] = None
):
self._permission_overrides = permission_overrides
"""Initialize OIDC and OAuth2 authentication/authorization
if basic_auth_credentials:
# Initialize basic auth (superusers with all permissions)
self.basic_auth.init(basic_auth_credentials)
OAuth2 JWKS URL is lazily fetched from the OIDC endpoint once it's needed for the first time.
This method is preferred over `init_oauth2_through_jwks` as you get all the
benefits of OIDC, with less configuration supplied.
"""
self._oauth2_audiences.extend(audiences or [])
self.oidc_discovery.init(oidc_discovery_url)

if jwks_url:
# # Initialize OAuth 2.0 - user permissions are required for all flows
# # except Client Credentials
self.oauth2_jwt.init(jwks_url, audiences=audiences or [])
def init_oauth2_through_jwks(
self, jwks_uri: str, *, audiences: Iterable[str] = None
):
"""Initialize OAuth2
It's recommended to use `init_oauth2_through_oidc` instead.
"""
self._oauth2_audiences.extend(audiences or [])
self.oauth2_jwt.init(jwks_uri, audiences=self._oauth2_audiences)

if oidc_discovery_url and self.oauth2_jwt.is_configured():
self.oidc_discovery.init(oidc_discovery_url)
def add_permission_overrides(self, overrides: PermissionOverrides):
"""Add wildcard or specific permissions to basic auth and/or OAuth2 users
Example:
security = FastAPISecurity()
create_product = security.user_permission("products:create")
# Give all permissions to the user johndoe
security.add_permission_overrides({"johndoe": "*"})
# Give the OAuth2 user `7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients` the
# "products:create" permission.
security.add_permission_overrides({
"7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients": ["products:create"],
})
"""
self._permission_overrides.update(overrides)

@property
def user(self) -> Callable:
Expand All @@ -79,7 +103,7 @@ def user_with_info(self) -> Callable:
"""Dependency that returns User object with user info, authenticated or not"""

async def dependency(user_auth: UserAuth = Depends(self._user_auth)):
if user_auth.is_oauth2():
if user_auth.is_oauth2() and user_auth.access_token:
info = await self.oidc_discovery.get_user_info(user_auth.access_token)
else:
info = UserInfo.make_dummy()
Expand All @@ -94,26 +118,20 @@ def authenticated_user_with_info_or_401(self) -> Callable:
"""

async def dependency(user_auth: UserAuth = Depends(self._user_auth_or_401)):
if user_auth.is_oauth2():
if user_auth.is_oauth2() and user_auth.access_token:
info = await self.oidc_discovery.get_user_info(user_auth.access_token)
else:
info = UserInfo.make_dummy()
return User(auth=user_auth, info=info)

return dependency

def has_permission(self, permission: UserPermission) -> Callable:
"""Dependency that raises HTTP403 if the user is missing the given permission"""
def user_permission(self, identifier: str) -> UserPermission:
perm = self._user_permission_class(identifier)
self._all_permissions.append(perm)
return perm

async def dependency(
user: User = Depends(self.authenticated_user_or_401),
) -> User:
self._has_permission_or_raise_forbidden(user, permission)
return user

return dependency

def user_with_permissions(self, *permissions: UserPermission) -> Callable:
def user_holding(self, *permissions: UserPermission) -> Callable:
"""Dependency that returns the user if it has the given permissions, otherwise
raises HTTP403
"""
Expand All @@ -137,12 +155,17 @@ async def dependency(
),
http_credentials: HTTPAuthorizationCredentials = Depends(http_basic_scheme),
) -> Optional[UserAuth]:
if not any(
[self.oauth2_jwt.is_configured(), self.basic_auth.is_configured()]
):
oidc_configured = self.oidc_discovery.is_configured()
oauth2_configured = self.oauth2_jwt.is_configured()
basic_auth_configured = self.basic_auth.is_configured()

if not any([oidc_configured, oauth2_configured, basic_auth_configured]):
raise AuthNotConfigured()

if oidc_configured and not oauth2_configured:
jwks_uri = await self.oidc_discovery.get_jwks_uri()
self.init_oauth2_through_jwks(jwks_uri)

if bearer_credentials is not None:
bearer_token = bearer_credentials.credentials
access_token = await self.oauth2_jwt.parse(bearer_token)
Expand Down Expand Up @@ -199,16 +222,21 @@ def _raise_forbidden(self, required_permission: str):
)

def _maybe_override_permissions(self, user_auth: UserAuth) -> UserAuth:
overrides = (self._permission_overrides or {}).get(user_auth.subject)

if overrides is None:
return user_auth
overrides = self._permission_overrides.get(user_auth.subject)

all_permissions = registry.get_all_permissions()
all_permission_identifiers = [p.identifier for p in self._all_permissions]

if "*" in overrides:
return user_auth.with_permissions(all_permissions)
if overrides is None:
return user_auth.with_permissions(
[
incoming_id
for incoming_id in user_auth.permissions
if incoming_id in all_permission_identifiers
]
)
elif "*" in overrides:
return user_auth.with_permissions(all_permission_identifiers)
else:
return user_auth.with_permissions(
[p for p in overrides if p in all_permissions]
[p for p in overrides if p in all_permission_identifiers]
)
12 changes: 7 additions & 5 deletions fastapi_security/basic.py
@@ -1,18 +1,18 @@
import secrets
from typing import Dict, List, Union
from typing import Dict, Iterable, List, Union

from fastapi.security.http import HTTPBasicCredentials

__all__ = ()
__all__ = ("HTTPBasicCredentials",)

ListOfCredentials = List[Union[HTTPBasicCredentials, Dict]]
IterableOfHTTPBasicCredentials = Iterable[Union[HTTPBasicCredentials, Dict]]


class BasicAuthValidator:
def __init__(self):
self._credentials = []

def init(self, credentials: ListOfCredentials):
def init(self, credentials: IterableOfHTTPBasicCredentials):
self._credentials = self._make_credentials(credentials)

def is_configured(self) -> bool:
Expand All @@ -29,7 +29,9 @@ def validate(self, credentials: HTTPBasicCredentials) -> bool:
for c in self._credentials
)

def _make_credentials(self, credentials: ListOfCredentials):
def _make_credentials(
self, credentials: IterableOfHTTPBasicCredentials
) -> List[HTTPBasicCredentials]:
return [
c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c)
for c in credentials
Expand Down

0 comments on commit b71b841

Please sign in to comment.