diff --git a/blacksheep/docs/authentication.md b/blacksheep/docs/authentication.md index 6fd50db..01c4c66 100644 --- a/blacksheep/docs/authentication.md +++ b/blacksheep/docs/authentication.md @@ -7,8 +7,12 @@ covers: - [X] How to use the built-in authentication strategy. - [X] How to configure a custom authentication handler. -- [X] How to use the built-in support for JWT Bearer authentication. +- [X] How to use the built-in support for **API Key** authentication. +- [X] How to use the built-in support for **Basic** authentication. +- [X] How to use the built-in support for **JWT Bearer** authentication. +- [X] How to use the built-in support for **Cookie** authentication. - [X] How to read the user's context in request handlers. +- [X] How authentication can be documented in **OpenAPI Documentation**. /// admonition | Additional dependencies. type: warning @@ -18,27 +22,713 @@ Install them by running: `pip install blacksheep[full]`. /// -## Underlying library - -The authentication and authorization logic for BlackSheep is packaged and -published in a dedicated library: -[`guardpost`](https://github.com/neoteroi/guardpost) ([in -pypi](https://pypi.org/project/guardpost/)). - ## How to use built-in authentication Common strategies for identifying users in web applications include: -- Reading an `Authorization: Bearer xxx` request header containing a [JWT](https://jwt.io/introduction/). - with claims that identify the user. - Reading a signed token from a cookie. +- Handling API Keys sent in custom headers. +- Handling basic credentials sent in `Authorization: Basic ***` headers. +- Handling JSON Web Tokens (JWTs) signed and including payloads with information + about the user, transmitted using `Authorization: Bearer ***` request headers. + +The following sections describe how to enable authentication using built-in +classes, and how to define custom authentication handlers. + +## API Key authentication + +Since version `2.4.2`, BlackSheep provides built-in support for API Key +authentication with flexible configuration options. API Keys can be read from +request headers, query parameters, or cookies, and each key can be associated +with specific roles and claims. + +### Enabling API Key authentication + +The following example illustrates how API Key authentication can be enabled: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.apikey import APIKey, APIKeyAuthentication +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +app = Application() + + +app.use_authentication().add( + APIKeyAuthentication( + APIKey( + secret=Secret("$API_SECRET"), # ⟵ obtained from API_SECRET env var + roles=["user"], # ⟵ optional roles + ), + param_name="X-API-Key", + ) +) + +app.use_authorization() + + +@auth() # requires authorization +@get("/") +async def get_claims(request): + return request.user.roles +``` + +You can configure multiple API Keys with different roles and claims: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.apikey import APIKey, APIKeyAuthentication +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +app = Application() + +app.use_authentication().add( + APIKeyAuthentication( + # Admin API key with full access + APIKey( + secret=Secret("$ADMIN_API_KEY"), + roles=["admin", "user"], + claims={"department": "IT"} + ), + # Regular user API key + APIKey( + secret=Secret("$USER_API_KEY"), + roles=["user"], + claims={"department": "sales"} + ), + # Read-only API key + APIKey( + secret=Secret("$READONLY_API_KEY"), + roles=["readonly"], + claims={} + ), + param_name="X-API-Key", + ) +) + +app.use_authorization() + + +@auth() +@get("/") +async def get_user_info(request): + return { + "roles": request.user.roles, + "claims": request.user.claims + } +``` + +### API Key locations + +API Keys can be retrieved from different locations in the request: + +=== "Header (default)" + + ```python + app.use_authentication().add( + APIKeyAuthentication( + APIKey(secret=Secret("your-secret-key")), + param_name="X-API-Key", + location="header" # Default location + ) + ) + ``` + + Test with: `curl -H "X-API-Key: your-secret-key" http://localhost:8000/` + +=== "Query" + + ```python + app.use_authentication().add( + APIKeyAuthentication( + APIKey(secret=Secret("your-secret-key")), + param_name="api_key", + location="query" + ) + ) + ``` + + Test with: `curl http://localhost:8000/?api_key=your-secret-key` + +=== "Cookie" + + ```python + app.use_authentication().add( + APIKeyAuthentication( + APIKey(secret=Secret("your-secret-key")), + param_name="api_key", + location="cookie" + ) + ) + ``` + + Test with: `curl -b "api_key=your-secret-key" http://localhost:8000/` + +### Dynamic API Key provider + +For scenarios where API Keys need to be retrieved dynamically (e.g., from a database), +implement the `APIKeysProvider` abstract class: + +```python +from typing import List +from blacksheep import Application, get +from blacksheep.server.authentication.apikey import ( + APIKey, + APIKeyAuthentication, + APIKeysProvider +) +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +class DatabaseAPIKeysProvider(APIKeysProvider): + """ + Example provider that retrieves API keys from a database. + """ + + def __init__(self, db_connection): + self.db = db_connection + + async def get_keys(self) -> List[APIKey]: + """ + Fetch API keys from database with associated roles and claims. + """ + # Example database query (adapt to your database) + keys_data = await self.db.fetch_all(""" + SELECT secret, roles, department, access_level + FROM api_keys + WHERE is_active = true + """) + + api_keys = [] + for row in keys_data: + api_keys.append(APIKey( + secret=Secret(row["secret"], direct_value=True), + roles=row["roles"].split(",") if row["roles"] else [], + claims={ + "department": row["department"], + "access_level": row["access_level"] + } + )) + + return api_keys + + +# Usage with dynamic provider +app = Application() + +# Assume you have a database connection +# db_connection = get_database_connection() + +app.use_authentication().add( + APIKeyAuthentication( + param_name="X-API-Key", + keys_provider=DatabaseAPIKeysProvider(db_connection) + ) +) + +app.use_authorization() + + +@auth() +@get("/") +async def protected_endpoint(request): + return { + "message": "Access granted", + "user_department": request.user.claims.get("department"), + "access_level": request.user.claims.get("access_level") + } +``` + +**Note:** dependency injection is also supported, configuring the +authentication handler as a _type_ to be instantiated rather than an instance. + +### Advanced API Key configuration + +You can customize the authentication scheme and add descriptions: + +```python +app.use_authentication().add( + APIKeyAuthentication( + APIKey( + secret=Secret("$API_SECRET"), + roles=["service"], + claims={"client_type": "external_service"} + ), + param_name="X-Service-Key", + scheme="ServiceKey", # Custom scheme name + location="header", + description="External service authentication using API keys" + ) +) +``` + +## Basic authentication + +Since version `2.4.2`, BlackSheep provides built-in support for HTTP Basic +Authentication, which allows clients to authenticate using a username and password +combination. Basic authentication credentials can be configured statically or retrieved +dynamically from external sources. + +### Enabling Basic authentication + +The following example shows how to configure Basic authentication with static +credentials: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.basic import BasicAuthentication, BasicCredentials +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +app = Application() + +app.use_authentication().add( + BasicAuthentication( + BasicCredentials( + username="admin", + password=Secret("$ADMIN_PASSWORD"), # ⟵ obtained from ADMIN_PASSWORD env var + roles=["admin"], # ⟵ optional roles + ), + BasicCredentials( + username="user", + password=Secret("$USER_PASSWORD"), # ⟵ obtained from USER_PASSWORD env var + roles=["user"], # ⟵ optional roles + ) + ) +) + +app.use_authorization() + + +@auth() # requires authorization +@get("/") +async def get_claims(request): + return request.user.roles +``` + +You can configure multiple users with different roles and claims: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.basic import BasicAuthentication, BasicCredentials +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +app = Application() + +app.use_authentication().add( + BasicAuthentication( + # Admin user with full access + BasicCredentials( + username="admin", + password=Secret("$ADMIN_PASSWORD"), + roles=["admin", "user"], + claims={"department": "IT", "level": "admin"} + ), + # Regular user + BasicCredentials( + username="john_doe", + password=Secret("$JOHN_PASSWORD"), + roles=["user"], + claims={"department": "sales", "level": "user"} + ), + # Read-only user + BasicCredentials( + username="guest", + password=Secret("$GUEST_PASSWORD"), + roles=["readonly"], + claims={"department": "public", "level": "guest"} + ) + ) +) + +app.use_authorization() + + +@auth() +@get("/") +async def get_user_info(request): + return { + "username": request.user.claims.get("sub"), + "roles": request.user.roles, + "claims": request.user.claims + } +``` + +Test with curl: +```bash +# Admin user +curl -u "admin:admin_password_here" http://localhost:8000/ + +# Regular user +curl -u "john_doe:john_password_here" http://localhost:8000/ + +# Guest user +curl -u "guest:guest_password_here" http://localhost:8000/ +``` + +### Dynamic credentials provider + +For scenarios where credentials need to be retrieved dynamically (e.g., from a database +or LDAP), implement the `BasicCredentialsProvider` abstract class: + +```python +from typing import List +from blacksheep import Application, get +from blacksheep.server.authentication.basic import ( + BasicAuthentication, + BasicCredentials, + BasicCredentialsProvider +) +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + + +class DatabaseCredentialsProvider(BasicCredentialsProvider): + """ + Example provider that retrieves credentials from a database. + """ + + def __init__(self, db_connection): + self.db = db_connection + + async def get_credentials(self) -> List[BasicCredentials]: + """ + Fetch credentials from database with associated roles and claims. + """ + # Example database query (adapt to your database) + users_data = await self.db.fetch_all(""" + SELECT username, password_hash, roles, department, access_level + FROM users + WHERE is_active = true + """) + + credentials = [] + for row in users_data: + # TODO: return a custom subclass of BasicCredentials that overrides the + # `match` method to handle the password_hash (as the client will send a + # password in clear text!) + credentials.append(BasicCredentials( + username=row["username"], + password=Secret(row["password_hash"], direct_value=True), + roles=row["roles"].split(",") if row["roles"] else [], + claims={ + "department": row["department"], + "access_level": row["access_level"] + } + )) + + return credentials + + +# Usage with dynamic provider +app = Application() + +# Assume you have a database connection +# db_connection = get_database_connection() + +app.use_authentication().add( + BasicAuthentication( + credentials_provider=DatabaseCredentialsProvider(db_connection) + ) +) + +app.use_authorization() -The following sections first explain how to use the built-in support for JWT -Bearer tokens and then describe how to write a custom authentication handler. -/// admonition | Terms: user, service, principal. +@auth() +@get("/") +async def protected_endpoint(request): + return { + "message": "Access granted", + "username": request.user.claims.get("sub"), + "department": request.user.claims.get("department"), + "access_level": request.user.claims.get("access_level") + } +``` + +**Note:** dependency injection is also supported, configuring the authentication handler as a _type_ to be instantiated rather than an instance. -The term 'user' typically refers to human users, while 'service' describes non-human clients. In Java and .NET, the term 'principal' is commonly used to describe a generic identity. +### Generating authorization headers + +The `BasicCredentials` class provides a utility method to generate the Authorization header value: + +```python +from blacksheep.server.authentication.basic import BasicCredentials +from essentials.secrets import Secret + +# Create credentials +admin_credentials = BasicCredentials( + username="admin", + password=Secret("secret_password", direct_value=True) +) + +# Generate the Authorization header value +header_value = admin_credentials.to_header_value() +print(header_value) # Output: Basic YWRtaW46c2VjcmV0X3Bhc3N3b3Jk + +# Use in HTTP client +import httpx + +response = httpx.get( + "http://localhost:8000/protected", + headers={"Authorization": header_value} +) +``` + +### Advanced configuration + +You can customize the authentication scheme: + +```python +app.use_authentication().add( + BasicAuthentication( + BasicCredentials( + username="service", + password=Secret("$SERVICE_PASSWORD"), + roles=["service"], + claims={"client_type": "internal_service"} + ), + scheme="InternalBasic", # Custom scheme name + description="Internal service authentication using Basic auth" + ) +) +``` + +/// admonition | Security recommendations + type: warning + +When implementing Basic authentication: + +- **Always use HTTPS** in production to protect credentials in transit. +- Use strong, unique passwords and consider password policies. +- If you store password in a database, store hashes with salt, not plain text passwords. + If you work with hashes and salts, define a subclass of `BasicCredentials` that + overrides the `match` method to handle hashes according to your preference. + +/// + +## Cookie + +BlackSheep implements a built-in class for Cookie authentication. This class can be +used to authenticate users based on a cookie, and it is used internally by default with +the OIDC integration (after a user successfully signs-in with an external identity +provider, the user context is stored in a cookie by default). + +Cookie authentication automatically handles setting, validating, and unsetting cookies +with signed and encrypted user data using `itsdangerous.Serializer`. + +### Basic Cookie authentication setup + +The following example shows how to configure Cookie authentication: + +```python +from blacksheep import Application, get, json +from blacksheep.server.authentication.cookie import CookieAuthentication +from blacksheep.server.authorization import auth + +app = Application() + +# Configure cookie authentication +app.use_authentication().add( + CookieAuthentication( + cookie_name="user_session", # Default: "identity" + secret_keys=["your-secret-key"], # Keys for signing/encryption + auth_scheme="CookieAuth" # Custom scheme name + ) +) + +app.use_authorization() + + +@auth() +@get("/profile") +async def get_profile(request): + return { + "message": "User profile", + "user": request.user.claims + } + + +@get("/login") +async def login(request): + """Example login endpoint that sets authentication cookie""" + response = json({"message": "Login successful"}) + + # Get the cookie authentication handler + cookie_auth = app.services.resolve(CookieAuthentication) + + # Set user data in cookie (typically done after validating credentials) + user_data = { + "sub": "user123", + "name": "John Doe", + "roles": ["user"], + "exp": 1234567890 # Optional expiration timestamp + } + + cookie_auth.set_cookie(user_data, response, secure=True) + return response + + +@get("/logout") +async def logout(request): + """Example logout endpoint that removes authentication cookie""" + response = json({"message": "Logged out"}) + + # Get the cookie authentication handler + cookie_auth = app.services.resolve(CookieAuthentication) + + # Remove the authentication cookie + cookie_auth.unset_cookie(response) + return response +``` + +### Advanced Cookie configuration + +You can customize the cookie authentication with additional options: + +```python +from blacksheep import Application +from blacksheep.server.authentication.cookie import CookieAuthentication +from itsdangerous import JSONWebSignatureSerializer + +app = Application() + +# Advanced configuration with custom serializer +custom_serializer = JSONWebSignatureSerializer("your-secret-key") + +app.use_authentication().add( + CookieAuthentication( + cookie_name="app_session", + secret_keys=["primary-key", "backup-key"], # Key rotation support + serializer=custom_serializer, # Custom serializer + auth_scheme="CustomCookieAuth" + ) +) +``` + +### Working with cookie data + +The cookie authentication handler provides methods to manage authentication cookies: + +```python +from blacksheep import Application, get, post, json +from blacksheep.server.authentication.cookie import CookieAuthentication + +app = Application() + +cookie_auth = CookieAuthentication( + cookie_name="session", + secret_keys=["your-secret-key"] +) + +app.use_authentication().add(cookie_auth) + + +@post("/api/signin") +async def signin(request): + """Sign in endpoint that validates credentials and sets cookie""" + # TODO: Validate user credentials from request body + + response = json({"success": True}) + + # Set authentication cookie with user claims + user_claims = { + "sub": "user123", + "email": "user@example.com", + "roles": ["user", "admin"], + "department": "IT" + } + + cookie_auth.set_cookie(user_claims, response, secure=True) + return response + + +@post("/api/signout") +async def signout(request): + """Sign out endpoint that removes the authentication cookie""" + response = json({"message": "Signed out successfully"}) + cookie_auth.unset_cookie(response) + return response + + +@get("/api/user") +async def get_current_user(request): + """Get current user info from cookie authentication""" + if request.user and request.user.is_authenticated(): + return json({ + "authenticated": True, + "claims": request.user.claims + }) + else: + return json({"authenticated": False}) +``` + +### Cookie security considerations + +When using cookie authentication, consider these security practices: + +```python +from blacksheep import Application +from blacksheep.server.authentication.cookie import CookieAuthentication +from datetime import datetime, timedelta + +app = Application() + +# Secure cookie configuration +app.use_authentication().add( + CookieAuthentication( + cookie_name="secure_session", + secret_keys=[ + "primary-secret-key-256-bits-long", + "backup-secret-key-for-rotation" + ] + ) +) + + +@app.route("/login", methods=["POST"]) +async def secure_login(request): + # TODO: Validate credentials + + response = json({"success": True}) + + # Set cookie with expiration + user_data = { + "sub": "user123", + "name": "John Doe", + "exp": int((datetime.utcnow() + timedelta(hours=24)).timestamp()) + } + + cookie_auth = app.services.resolve(CookieAuthentication) + cookie_auth.set_cookie( + user_data, + response, + secure=True # Always use secure=True in production with HTTPS + ) + return response +``` + +/// admonition | Security recommendations + type: warning + +When implementing Cookie authentication: + +- **Do not** hard-code secrets in source code. The examples above are just **examples**. +- **Use strong secret keys**: Generate cryptographically secure random keys, + for example using `secrets.choice`. +- **Enable secure flag**: Always set `secure=True` when using HTTPS in production. +- **Key rotation**: Use multiple secret keys to support key rotation without breaking + existing sessions. +- **Set expiration**: Include `exp` claim in cookie data to control session lifetime. +- **Use HTTPS**: Never transmit authentication cookies over unencrypted connections. /// @@ -108,6 +798,8 @@ For more information and examples, refer to the dedicated page about ## JWT Bearer +### With Asymmetric Encryption + BlackSheep implements built-in support for JWT Bearer authentication, and validation of JWTs: @@ -165,9 +857,8 @@ async def open(user: User | None): ``` -The built-in handler for JWT Bearer authentication does not currently support -JWTs signed with symmetric keys. Support for symmetric keys might be added in -the future. +The built-in handler for JWT Bearer authentication also supports symmetric encryption, +but only since version `2.4.2`. /// admonition | 💡 @@ -178,6 +869,158 @@ Active Directory, Azure Active Directory B2C. /// +### With Symmetric Encryption + +Since version `2.4.2`, BlackSheep supports JWT Bearer authentication with symmetric +encryption using shared secret keys. This is useful for scenarios where you control both +the token issuer and validator, such as internal services or microservices +architectures. + +The following example shows how to configure JWT Bearer authentication with a symmetric +secret key: + +```python +from blacksheep import Application, get, json +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from blacksheep.server.authorization import auth +from essentials.secrets import Secret + +app = Application() + +app.use_authentication().add( + JWTBearerAuthentication( + secret_key=Secret("$JWT_SECRET"), # ⟵ obtained from JWT_SECRET env var + valid_audiences=["my-service"], + valid_issuers=["my-issuer"], + algorithms=["HS256"], # ⟵ symmetric algorithms: HS256, HS384, HS512 + auth_mode="JWT Symmetric" + ) +) + +app.use_authorization() + + +@auth() +@get("/protected") +async def protected_endpoint(request): + return { + "message": "Access granted", + "user": request.user.claims.get("sub"), + "roles": request.user.claims.get("roles", []) + } +``` + +#### Supported symmetric algorithms + +When using symmetric encryption, the following algorithms are supported: + +- `HS256` (HMAC using SHA-256) - **recommended** +- `HS384` (HMAC using SHA-384) +- `HS512` (HMAC using SHA-512) + +#### Creating symmetric JWTs + +You can create JWTs for testing using Python's `PyJWT` library: + +```python +import jwt +from datetime import datetime, timedelta + +# Your shared secret (same as in the authentication config) +secret = "your-secret-key-here" + +# Create a JWT payload +payload = { + "sub": "user123", + "aud": "my-service", + "iss": "my-issuer", + "exp": datetime.utcnow() + timedelta(hours=1), + "iat": datetime.utcnow(), + "roles": ["user", "admin"] +} + +# Generate the token +token = jwt.encode(payload, secret, algorithm="HS256") +print(f"Token: {token}") + +# Test with curl +# curl -H "Authorization: Bearer {token}" http://localhost:8000/protected +``` + +#### Multiple JWT configurations + +You can configure both symmetric and asymmetric JWT authentication handlers in the same +application to support different token types: + +```python +from blacksheep import Application +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from essentials.secrets import Secret + +app = Application() + +# Symmetric JWT for internal services +app.use_authentication().add( + JWTBearerAuthentication( + secret_key=Secret("$INTERNAL_JWT_SECRET"), + valid_audiences=["internal-api"], + valid_issuers=["internal-issuer"], + algorithms=["HS256"], + auth_mode="JWT Internal" + ) +) + +# Asymmetric JWT for external identity providers +app.use_authentication().add( + JWTBearerAuthentication( + authority="https://login.microsoftonline.com/tenant.onmicrosoft.com", + valid_audiences=["external-client-id"], + valid_issuers=["https://login.microsoftonline.com/tenant-id/v2.0"], + algorithms=["RS256"], + auth_mode="JWT External" + ) +) +``` + +/// admonition | Symmetric vs Asymmetric + type: info + +**Symmetric encryption** (shared secret): + +- ✅ Faster validation (no key fetching required) +- ✅ Simpler setup for internal services +- ❌ Same key used for signing and validation +- ❌ Key distribution challenges in distributed systems + +**Asymmetric encryption** (public/private keys): + +- ✅ Better security model (separate keys for signing/validation) +- ✅ Better for third-party integrations +- ❌ Slower validation (key fetching and cryptographic operations) +- ❌ More complex setup + +Choose symmetric encryption for internal services where you control both token creation +and validation. Use asymmetric encryption when integrating with external identity +providers or when you need to distribute validation capabilities without sharing signing +keys. + +/// + +/// admonition | Security considerations + type: warning + +When using symmetric JWT authentication: + +- **Use strong secret keys**: Generate cryptographically secure random keys of at least + 256 bits for HS256. +- **Protect your secrets**: Store secret keys securely and never commit them to version + control. +- **Key rotation**: Implement a strategy for rotating secret keys periodically. +- **Secure transmission**: Always use HTTPS in production to protect tokens in transit. +- **Token expiration**: Set appropriate expiration times (`exp` claim) for your tokens. + +/// + ## Writing a custom authentication handler The example below shows how to configure a custom authentication handler that @@ -361,15 +1204,78 @@ def home(request: Request): /// admonition | ContainerProtocol. type: tip -As documented in [_Container Protocol_](./dependency-injection.md#the-container-protocol), BlackSheep -supports the use of other DI containers as replacements for the built-in -library used for dependency injection. +As documented in [_Container Protocol_](./dependency-injection.md#the-container-protocol), +BlackSheep supports the use of other DI containers as replacements for the +built-in library used for dependency injection. /// + +### Error handling and security considerations + +When using authentication and authorization, consider these security practices: + +```python +from blacksheep import Application, get, json +from blacksheep.server.authentication... +from blacksheep.exceptions import Unauthorized +from essentials.secrets import Secret + + +app = Application() + +app.use_authentication().add( + ... +) + +app.use_authorization() + + +@get("/public") +async def public_endpoint(): + """Public endpoint that doesn't require authentication.""" + return {"message": "This is public"} + + +@auth() +@get("/protected") +async def protected_endpoint(request): + """Protected endpoint that requires an authenticated user.""" + return { + "message": "Access granted", + "roles": request.user.roles + } + + +@get("/optional-auth") +async def optional_auth_endpoint(request): + """Endpoint with optional authentication.""" + if request.user and request.user.is_authenticated(): + return { + "message": "Authenticated user", + "roles": request.user.roles + } + else: + return {"message": "Anonymous user"} +``` + +## Underlying library + +The authentication and authorization logic for BlackSheep is packaged and +published in a dedicated library: +[`guardpost`](https://github.com/neoteroi/guardpost) ([in +pypi](https://pypi.org/project/guardpost/)). + ## Next While authentication focuses on *identifying* users, authorization determines whether a user *is permitted* to perform the requested action. The next page describes the built-in [authorization strategy](authorization.md) in BlackSheep. + +/// note | Documenting authentication. + +For information on how to document authentication schemes in OpenAPI +Specification files, refer to [_Documenting authentication_](./openapi.md#documenting-authentication). + +/// diff --git a/blacksheep/docs/authorization.md b/blacksheep/docs/authorization.md index a994568..8a3e8e3 100644 --- a/blacksheep/docs/authorization.md +++ b/blacksheep/docs/authorization.md @@ -7,6 +7,7 @@ This page covers: - [X] How to use the built-in authorization strategy. - [X] How to apply authorization rules to request handlers. +- [X] How to require roles in request handlers. It is recommended to review the [authentication documentation](authentication.md) before proceeding with this page. @@ -259,6 +260,83 @@ async def only_for_user_authenticated_with_github(): return ok("example") ``` +## Authorizing by roles + +/// tab | Since version 2.4.2 + +Since version `2.4.2`, the framework includes built-in features to require +_sufficient_ roles (any one is enough) to authorize web requests. The authenticated +user object must have a `roles` property of type `List[str]`. + +```python +from blacksheep.server.authorization import Policy, auth + + +app.use_authentication(...) # configure as desired + + +# requires a user with a roles property containing the string "admin" ↓ +@auth(roles=["admin"]) +async def only_for_admins(): + ... +``` + +Examples: + +- When using JWT Bearer authentication, the JWT payload must have a `roles` claim with + the desired roles, for authorization to succeed. +- When using Basic authentication or API Key authentication with the built-in classes, + refer to the documentation at [_Authentication_](./authentication.md) for examples + on how to configure roles or claims on the identity object obtained after successful + authentication. +- When using custom authentication handlers, implement the desired logic and configure + `Identity` objects with the desired roles. + +```python +class MyAuthenticationHandler(AuthenticationHandler): + def authenticate(self, context: Request) -> Identity | None: + # TODO: implement your own authentication logic, handle roles as desired + return Identity({"sub": "***", "roles": []}, self.scheme) +``` + +/// + +/// tab | Before version 2.4.2 + +Before `2.4.2`, the framework did not include any specific code to define +roles for authorization, and required defining a _Policy_ that would +check for the desired property on the request context. + +```python +from guardpost import ( + AuthenticationHandler, + Identity, + User, + AuthorizationContext, + Requirement, +) +from guardpost.common import AuthenticatedRequirement + + +class AdminRequirement(Requirement): + def handle(self, context: AuthorizationContext): + identity = context.identity + + # Your own logic to check identity claims… + if identity is not None and identity.claims.get("role") == "admin": + context.succeed(self) + +app.use_authentication(...) # configure as desired + +app.use_authorization().add(Policy("admin", AdminRequirement())) + +@auth("admin") +async def only_for_admins(): + ... +``` + +/// + ## Failure response codes When a request fails because of authorization reasons, the web framework diff --git a/blacksheep/docs/binders.md b/blacksheep/docs/binders.md index 579e7bf..957699d 100644 --- a/blacksheep/docs/binders.md +++ b/blacksheep/docs/binders.md @@ -1,4 +1,5 @@ # Binders + BlackSheep implements automatic binding of parameters for request handlers, a feature inspired by "Model Binding" in the [ASP.NET web framework](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/model-binding?view=aspnetcore-2.2). @@ -71,6 +72,7 @@ from `BoundValue` class, defined in `blacksheep.server.bindings`. the `Request` of the web request. ### Explicit binding + Binders can be defined explicitly, using type annotations and classes from `blacksheep.server.bindings` (or just `blacksheep`). @@ -115,6 +117,7 @@ extra unused properties, use `*args` in your class constructor: `__init__(one, two, three, *args)`. ## Optional parameters + Optional parameters can be defined in one of these ways: 1. using `typing.Optional` annotation @@ -273,3 +276,360 @@ def home(something: FromCustomValue): return f"OK {something.value}" ``` + +## Custom Convert Functions in BoundValue Classes + +Since version `2.4.1`, custom `BoundValue` classes can define a `convert` class method +to transform Python objects from parsed JSON into more specific types. This is +particularly useful when you need to apply custom validation or transformation logic +during the binding process. + +### Defining a Convert Function + +To add custom conversion logic to a `BoundValue` class, define a `convert` class method: + +```python +from typing import Any, Dict +from blacksheep import Application, FromJSON, post +from blacksheep.server.bindings import BoundValue + +class CustomData(BoundValue[Dict[str, Any]]): + """ + Custom bound value with conversion logic. + """ + + @classmethod + def convert(cls, value: Any) -> Dict[str, Any]: + """ + Convert the parsed JSON value into the desired format. + This method is called after JSON parsing but before creating the BoundValue + instance. + """ + if isinstance(value, dict): + # Apply custom validation and transformation + if 'required_field' not in value: + raise ValueError("Missing required_field in request data") + + # Transform the data + return { + 'processed': True, + 'original': value, + 'timestamp': value.get('timestamp', 'default_value') + } + + raise ValueError("Expected a dictionary object") + +app = Application() + +@post("/api/data") +async def process_data(data: FromJSON[CustomData]): + # data.value contains the converted dictionary + return { + "received": data.value, + "processed": data.value['processed'] + } +``` + +### Advanced Custom Conversion + +You can implement more complex conversion logic for specific use cases: + +```python +from dataclasses import dataclass +from datetime import datetime +from typing import Optional +from blacksheep import FromJSON, post +from blacksheep.server.bindings import BoundValue + +@dataclass +class UserProfile: + name: str + email: str + created_at: datetime + age: Optional[int] = None + +class UserProfileBinder(BoundValue[UserProfile]): + """ + Custom binder that converts JSON to UserProfile with date parsing. + """ + + @classmethod + def convert(cls, value: Any) -> UserProfile: + if not isinstance(value, dict): + raise ValueError("Expected a dictionary for UserProfile") + + # Parse the datetime string + created_at_str = value.get('created_at') + if isinstance(created_at_str, str): + try: + created_at = datetime.fromisoformat(created_at_str) + except ValueError: + raise ValueError("Invalid datetime format for created_at") + else: + created_at = datetime.utcnow() + + # Validate required fields + if not value.get('name') or not value.get('email'): + raise ValueError("Name and email are required fields") + + return UserProfile( + name=value['name'], + email=value['email'], + created_at=created_at, + age=value.get('age') + ) + +@post("/api/users") +async def create_user(profile: FromJSON[UserProfileBinder]): + user = profile.value # This is a UserProfile instance + return { + "message": f"User {user.name} created successfully", + "user_id": hash(user.email), + "created_at": user.created_at.isoformat() + } +``` + +### Error Handling in Convert Functions + +Convert functions should raise appropriate exceptions for invalid data: + +```python +from blacksheep.server.bindings import BoundValue +from blacksheep.exceptions import BadRequest + +class ValidatedInput(BoundValue[dict]): + @classmethod + def convert(cls, value: Any) -> dict: + if not isinstance(value, dict): + raise BadRequest("Expected JSON object") + + # Custom validation logic + if 'id' not in value: + raise BadRequest("Missing 'id' field") + + if not isinstance(value['id'], int) or value['id'] <= 0: + raise BadRequest("Field 'id' must be a positive integer") + + return value +``` + +When the `convert` method raises an exception, BlackSheep automatically returns a `400 +Bad Request` response with the error message. + +/// admonition | Convert Method Behavior + type: info + +- It receives the parsed Python object (dict, list, etc.) as input. +- The return value becomes the `value` property of the `BoundValue` instance. +- Exceptions raised in `convert` methods are automatically converted to `400 Bad + Request` responses. + +/// + +## Type Converters + +Since version `2.4.1`, BlackSheep provides a flexible type conversion system through the +`TypeConverter` abstract class. This system allows automatic conversion of string +representations from request parameters (query, headers, route, etc.) into specific +Python types. + +### Built-in Type Converters + +BlackSheep includes several built-in type converters that handle common data types: + +| Converter | Supported Types | Description | +| ------------------- | --------------- | ------------------------------------------------- | +| `StrConverter` | `str` | Handles string values with URL decoding | +| `BoolConverter` | `bool` | Converts "true"/"false", "1"/"0" to boolean | +| `IntConverter` | `int` | Converts strings to integers | +| `FloatConverter` | `float` | Converts strings to floating-point numbers | +| `UUIDConverter` | `UUID` | Converts strings to UUID objects | +| `BytesConverter` | `bytes` | Converts strings to bytes using UTF-8 encoding | +| `DateTimeConverter` | `datetime` | Parses ISO datetime strings | +| `DateConverter` | `date` | Parses ISO date strings | +| `StrEnumConverter` | `StrEnum` | Converts strings to StrEnum values (Python 3.11+) | +| `IntEnumConverter` | `IntEnum` | Converts strings to IntEnum values (Python 3.11+) | +| `LiteralConverter` | `Literal` | Validates against literal type values | + +### String Enum Support (Python 3.11+) + +BlackSheep provides automatic support for `StrEnum` types: + +```python +from enum import StrEnum +from blacksheep import Application, get + +app = Application() + +class Color(StrEnum): + RED = "red" + GREEN = "green" + BLUE = "blue" + +@get("/items") +async def get_items(color: Color): + # color parameter automatically converted to Color enum + return {"color": color.value, "name": color.name} + +# Usage examples: +# GET /items?color=red -> Color.RED +# GET /items?color=GREEN -> Color.GREEN (by name) +# GET /items?color=invalid -> 400 Bad Request +``` + +### Integer Enum Support (Python 3.11+) + +Similarly, `IntEnum` types are automatically supported: + +```python +from enum import IntEnum +from blacksheep import Application, get + +class Priority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + +@get("/tasks") +async def get_tasks(priority: Priority): + return {"priority": priority.value, "name": priority.name} + +# Usage examples: +# GET /tasks?priority=1 -> Priority.LOW +# GET /tasks?priority=HIGH -> Priority.HIGH (by name) +# GET /tasks?priority=5 -> 400 Bad Request +``` + +### Literal Type Support + +BlackSheep supports `typing.Literal` for restricting values to specific literals: + +```python +from typing import Literal +from blacksheep import Application, get + +@get("/api/data") +async def get_data(format: Literal["json", "xml", "csv"]): + return {"format": format, "message": f"Returning data in {format} format"} + +# Usage examples: +# GET /api/data?format=json -> format="json" +# GET /api/data?format=pdf -> 400 Bad Request + +# Case-insensitive literal matching +from blacksheep.server.bindings.converters import LiteralConverter + +# Configure case-insensitive matching (if needed globally) +# This would require custom binder configuration +``` + +### Custom Type Converter + +You can define custom type converters by implementing the `TypeConverter` abstract class: + +```python +from abc import abstractmethod +from blacksheep.server.bindings.converters import TypeConverter +from blacksheep.server.bindings.converters import converters +from blacksheep import Application, get + +# Custom type example +class ProductCode: + def __init__(self, code: str): + if not code.startswith("PROD-"): + raise ValueError("Product code must start with 'PROD-'") + if len(code) != 10: + raise ValueError("Product code must be exactly 10 characters") + self.code = code + + def __str__(self): + return self.code + +# Custom converter +class ProductCodeConverter(TypeConverter): + def can_convert(self, expected_type) -> bool: + return expected_type is ProductCode + + def convert(self, value, expected_type): + if value is None: + return None + try: + return ProductCode(value) + except ValueError as e: + raise ValueError(f"Invalid product code: {e}") + +# Register the custom converter +converters.append(ProductCodeConverter()) + +app = Application() + +@get("/products/{product_code}") +async def get_product(product_code: ProductCode): + return {"product_code": str(product_code)} + +# Usage examples: +# GET /products/PROD-12345 -> ProductCode("PROD-12345") +# GET /products/INVALID -> 400 Bad Request +``` + +### Advanced Converter Configuration + +For more complex scenarios, you can configure converters with custom options: + +```python +from blacksheep.server.bindings.converters import LiteralConverter +from blacksheep import FromQuery, get + +# Case-insensitive literal converter +case_insensitive_converter = LiteralConverter(case_insensitive=True) + +class CustomFromQuery(FromQuery[T]): + def __init__(self, default_value=None): + super().__init__(default_value) + # Custom converter logic could be added here + +@get("/search") +async def search( + sort_order: Literal["asc", "desc"] = "asc", + category: Literal["books", "movies", "games"] = "books" +): + # Both parameters support case-insensitive matching if configured + return {"sort_order": sort_order, "category": category} +``` + +### Error Handling in Type Conversion + +When type conversion fails, BlackSheep automatically returns a `400 Bad Request` response: + +```python +from enum import StrEnum +from blacksheep import Application, get + +class Status(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + +@get("/users") +async def get_users(status: Status): + return {"status": status} + +# GET /users?status=invalid -> 400 Bad Request with message: +# "invalid is not a valid Status" +``` + +### Type Converter Priority + +Converters are evaluated in the order they appear in the `converters` list. Built-in +converters are registered by default, and custom converters are typically appended to +the list. + +```python +from blacksheep.server.bindings.converters import converters + +# View all registered converters +for converter in converters: + print(f"{converter.__class__.__name__}: {converter}") + +# Add custom converter with priority (insert at beginning) +converters.insert(0, YourCustomConverter()) +``` diff --git a/blacksheep/docs/openapi.md b/blacksheep/docs/openapi.md index 703f0c4..0680e23 100644 --- a/blacksheep/docs/openapi.md +++ b/blacksheep/docs/openapi.md @@ -11,6 +11,7 @@ OpenAPI Specification file. This page describes the following: - [X] Expose the documentation for anonymous access. - [X] Options to display OpenAPI Documentation. - [X] How to implement a custom `UIProvider`. +- [X] How to document authentication schemes. ## Introduction to OpenAPI Documentation @@ -989,6 +990,307 @@ class CustomOpenAPIHandler(OpenAPIHandler): return handler.__name__.capitalize().replace("_", " ") ``` +## Documenting authentication + +Since version `2.4.2`, BlackSheep automatically generates OpenAPI Documentation for +authentication handlers when using the built-in [authentication classes](./authentication.md). +This means that when you configure API Key, Basic, or JWT Bearer, the +corresponding security schemes are automatically added to your OpenAPI +specification. + +### Automatic documentation for built-in handlers + +The following built-in authentication handlers are automatically documented: + +- **APIKeyAuthentication** → generates `ApiKey` security scheme. +- **BasicAuthentication** → generates `HTTP Basic` security scheme. +- **JWTBearerAuthentication** → generates `HTTP Bearer` security scheme with JWT format. + +```python +from blacksheep import Application +from blacksheep.server.authentication.apikey import APIKey, APIKeyAuthentication +from blacksheep.server.authentication.basic import BasicAuthentication, BasicCredentials +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from blacksheep.server.openapi.v3 import OpenAPIHandler +from openapidocs.v3 import Info +from essentials.secrets import Secret + +app = Application() + +# Configure OpenAPI documentation +docs = OpenAPIHandler(info=Info(title="My API", version="1.0.0")) +docs.bind_app(app) + +# These authentication handlers will be automatically documented +app.use_authentication().add( + APIKeyAuthentication( + APIKey(secret=Secret("$API_SECRET")), + param_name="X-API-Key", + description="API key for authentication" # ⟵ appears in OpenAPI docs + ) +).add( + BasicAuthentication( + BasicCredentials( + username="admin", + password=Secret("$ADMIN_PASSWORD") + ), + description="Basic authentication for admin users" # ⟵ appears in OpenAPI docs + ) +).add( + JWTBearerAuthentication( + authority="https://your-authority.com", + valid_audiences=["your-audience"] + # Automatically documented as Bearer JWT + ) +) +``` + +The generated OpenAPI specification will include: + +```yaml +components: + securitySchemes: + ApiKey: + type: apiKey + name: X-API-Key + in: header + description: API key for authentication + Basic: + type: http + scheme: basic + description: Basic authentication for admin users + Bearer: + type: http + scheme: bearer + bearerFormat: JWT +security: + - ApiKey: [] + - Basic: [] + - JWTBearerAuthentication: [] +``` + +### Custom SecuritySchemeHandler + +To control how your custom authentication handlers are documented, implement the +`SecuritySchemeHandler` abstract class: + +```python +from typing import Iterable, Tuple +from blacksheep import Application +from blacksheep.server.openapi.v3 import OpenAPIHandler, SecuritySchemeHandler +from blacksheep.server.authentication import AuthenticationHandler +from guardpost import AuthenticationHandler, Identity +from openapidocs.v3 import Info, SecurityScheme, HTTPSecurity, SecurityRequirement + +# Custom authentication handler +class CustomTokenAuthentication(AuthenticationHandler): + def __init__(self, scheme: str = "CustomToken"): + self.scheme = scheme + self.description = "Custom token authentication" + + async def authenticate(self, context) -> Identity | None: + # Your custom authentication logic here + token = context.get_first_header(b"X-Custom-Token") + if token: + # Validate token and return identity + return Identity({"sub": "user123"}, self.scheme) + return None + + +# Custom security scheme handler +class CustomTokenSecuritySchemeHandler(SecuritySchemeHandler): + def get_security_schemes( + self, handler: AuthenticationHandler + ) -> Iterable[Tuple[str, SecurityScheme, SecurityRequirement]]: + if isinstance(handler, CustomTokenAuthentication): + yield handler.scheme, HTTPSecurity( + scheme="bearer", + bearer_format="CustomToken", + description=handler.description + ), SecurityRequirement(handler.scheme, []) + + +app = Application() + +# Configure OpenAPI with custom security scheme handler +docs = OpenAPIHandler(info=Info(title="My API", version="1.0.0")) +docs.security_schemes_handlers.append(CustomTokenSecuritySchemeHandler()) +docs.bind_app(app) + +# Configure authentication +app.use_authentication().add(CustomTokenAuthentication()) +``` + +### Multiple authentication methods + +When you configure multiple authentication handlers, they are all documented and +the OpenAPI specification allows clients to choose any of the supported methods: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.apikey import APIKey, APIKeyAuthentication +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from blacksheep.server.authorization import auth +from blacksheep.server.openapi.v3 import OpenAPIHandler +from openapidocs.v3 import Info +from essentials.secrets import Secret + +app = Application() + +docs = OpenAPIHandler(info=Info(title="Multi-Auth API", version="1.0.0")) +docs.bind_app(app) + +# Configure multiple authentication methods +app.use_authentication().add( + # API Key authentication + APIKeyAuthentication( + APIKey(secret=Secret("$API_SECRET")), + param_name="X-API-Key", + description="API key authentication" + ) +).add( + # JWT Bearer authentication + JWTBearerAuthentication( + authority="https://your-authority.com", + valid_audiences=["your-audience"] + ) +) + +app.use_authorization() + + +@auth() # Accepts either API Key or JWT Bearer +@get("/protected") +async def protected_endpoint(): + return {"message": "Authenticated successfully"} +``` + +This generates OpenAPI documentation that shows both authentication methods are +supported, and clients can use either one. + +### Endpoint-specific authentication requirements + +You can document different authentication requirements for different endpoints: + +```python +from blacksheep import Application, get +from blacksheep.server.authentication.apikey import APIKey, APIKeyAuthentication +from blacksheep.server.authentication.basic import BasicAuthentication, BasicCredentials +from blacksheep.server.authentication.jwt import JWTBearerAuthentication +from blacksheep.server.authorization import allow_anonymous, auth +from blacksheep.server.openapi.common import SecurityInfo +from blacksheep.server.openapi.v3 import OpenAPIHandler, Info +from essentials.secrets import Secret + + +app = Application() + + +app.use_authentication().add( + APIKeyAuthentication( + APIKey( + secret=Secret("$API_SECRET"), # Obtained from API_SECRET env var + roles=["user"], + ), + param_name="X-API-Key", + ) +).add( + BasicAuthentication( + BasicCredentials( + username="admin", + password=Secret("$ADMIN_PASSWORD"), # Obtained from ADMIN_PASSWORD env var + roles=["admin"], + ) + ) +).add( + JWTBearerAuthentication( + valid_audiences=["myaudience"], + valid_issuers=["myapp"], + secret_key=Secret("$JWT_SECRET"), # Obtained from JWT_SECRET env var + ) +) + +app.use_authorization() + + +# See the generated docs and how they include security sections +docs = OpenAPIHandler(info=Info(title="Example API", version="0.0.1")) +docs.bind_app(app) + + +@auth(authentication_schemes=["ApiKey"]) # Only API Key authentication +@docs(security=[SecurityInfo("ApiKey", [])]) +@get("/api-key-only") +async def api_key_only(): + return {"message": "API Key required"} + + +@auth(authentication_schemes=["Bearer"]) # Only JWT Bearer authentication +@docs(security=[SecurityInfo("Bearer", [])]) +@get("/jwt-only") +async def jwt_only(): + return {"message": "JWT Bearer required"} + + +@allow_anonymous() +@get("/public") +async def public_endpoint(): + return {"message": "Public endpoint"} +``` + +### For more granular control + +Before version `2.4.2`, or for full control on the generated OpenAPI +specification file, you can manually configure security schemes using +the `on_docs_created` event handler: + +```python +from blacksheep import Application +from blacksheep.server.openapi.v3 import OpenAPIHandler +from openapidocs.v3 import Info, APIKeySecurity, ParameterLocation, SecurityRequirement + +app = Application() + +docs = OpenAPIHandler(info=Info(title="My API", version="1.0.0")) + + +# Manual security scheme configuration +@docs.events.on_docs_created +def configure_security_schemes(openapi_docs): + # Manually add security schemes + if openapi_docs.components is None: + openapi_docs.components = Components() + if openapi_docs.components.security_schemes is None: + openapi_docs.components.security_schemes = {} + + # Add API Key security scheme + openapi_docs.components.security_schemes["ApiKey"] = APIKeySecurity( + name="X-API-Key", + in_=ParameterLocation.HEADER, + description="API key for authentication" + ) + + # Set global security requirement + openapi_docs.security = [SecurityRequirement("ApiKey", [])] + + +docs.bind_app(app) + +# Configure your custom authentication handler +# app.use_authentication().add(YourCustomHandler()) +``` + +/// admonition | Recommendation. + type: tip + +**Use built-in handlers when possible**: The built-in authentication handlers +(APIKeyAuthentication, BasicAuthentication, JWTBearerAuthentication) provide +automatic OpenAPI documentation and are well-tested. + +**For custom handlers**: Implement a dedicated `SecuritySchemeHandler` to +ensure your authentication methods are documented in the OpenAPI specification. + +/// ### For more details diff --git a/blacksheep/docs/opentelemetry.md b/blacksheep/docs/opentelemetry.md index 45a27cd..35030b5 100644 --- a/blacksheep/docs/opentelemetry.md +++ b/blacksheep/docs/opentelemetry.md @@ -19,6 +19,17 @@ automatically trace incoming requests, outgoing HTTP calls, and other operations, providing end-to-end visibility into your application's execution flow. +/// note | Tutorial on self-hosted monitoring stack. + +For a tutorial on how to self-host a full monitoring stack in a single node of +a Kubernetes cluster (which is fine for non-production environments), and to +know more about OpenTelemetry and the OTLP protocol, you can read: +[_K8s Studies on Monitoring_](https://robertoprevato.github.io/K8sStudies/k3s/monitoring/). +It includes a working example of BlackSheep application that sends telemetries +to an OpenTelemetry Collector. + +/// + ## Enabling OpenTelemetry in BlackSheep BlackSheep offers built-in support for OpenTelemetry since version `2.3.2`, but @@ -155,9 +166,9 @@ A trace is produced for each web request and for all handled and unhandled exceptions. For unhandled exceptions, OpenTelemetry includes the full stacktrace of the exception. -[![Grafana traces](./img/grafana-traces.png)](./img/grafana-traces.png) +![Grafana traces](./img/grafana-traces.png) -[![Grafana errors](./img/grafana-errors.png)](./img/grafana-errors.png) +![Grafana errors](./img/grafana-errors.png) ## Example: Azure Application Insights @@ -205,7 +216,7 @@ use_application_insights(app, "YOUR_CONN_STRING") Observe how web requests and errors are displayed: -[![Azure Application Insights Requests](./img/azure-app-insights-requests.png)](./img/azure-app-insights-requests.png) +![Azure Application Insights Requests](./img/azure-app-insights-requests.png) ## Logging dependencies @@ -233,9 +244,9 @@ async def home(request) -> Response: The following screenshots illustrate how dependencies are displayed in Grafana and Azure Application Insights: -[![Dependency in Grafana](./img/grafana-dependency.png)](./img/grafana-dependency.png) +![Dependency in Grafana](./img/grafana-dependency.png) -[![Dependency in Azure Application Insights](./img/azure-app-insights-dependency.png)](./img/azure-app-insights-dependency.png) +![Dependency in Azure Application Insights](./img/azure-app-insights-dependency.png) /// admonition | Dependencies with Azure Application Insights. type: example @@ -294,7 +305,7 @@ async def home(request) -> Response: logger.warning("Example warning") ``` -[![Logs sent to backend](./img/grafana-logs.png)](./img/grafana-logs.png) +![Logs sent to backend](./img/grafana-logs.png) To filter by a different priority, set a different level on the root logger: @@ -305,7 +316,7 @@ import logging logging.getLogger().setLevel(logging.INFO) ``` -[![Logs sent to backend](./img/grafana-logs-info.png)](./img/grafana-logs-info.png) +![Logs sent to backend](./img/grafana-logs-info.png) ## Service information diff --git a/blacksheep/mkdocs.yml b/blacksheep/mkdocs.yml index 938987b..6907e3b 100644 --- a/blacksheep/mkdocs.yml +++ b/blacksheep/mkdocs.yml @@ -90,6 +90,7 @@ extra_javascript: plugins: - search + - glightbox - neoteroi.contribs: enabled_by_env: "GIT_CONTRIBS_ON" diff --git a/requirements.txt b/requirements.txt index a6a7f7f..8f4275a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ mkdocs-material==9.6.9 neoteroi-mkdocs>=1.1.1 +mkdocs-glightbox==0.5.1