5

I'm currently authenticating with basic, following this tutorial:

import secrets

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials,

http_basic = HTTPBasic()

def authorize_basic(credentials: HTTPBasicCredentials = Depends(http_basic)):
    correct_username = secrets.compare_digest(credentials.username, "test")
    correct_password = secrets.compare_digest(credentials.password, "test")
    if not (correct_username and correct_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Basic"},
        )

@app.get("/auth/", dependencies=[Depends(authorize_basic)])
def auth():
    return {"success": "true"}

How do I use HTTPDigest instead?

2 Answers 2

2

I know this question was asked quite a while ago but this example from the FastAPI test suite shows how to do it.

You can rewrite the above example as follows:

import base64
import secrets

from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest

http_digest = HTTPDigest()

app = FastAPI()


def authorize_digest(credentials: HTTPAuthorizationCredentials = Security(http_digest)):
    # Credentials returns the token as string.
    incoming_token = credentials.credentials

    # Let's say you want to generate the digest token from username and pass.
    expected_username = "test"
    expected_password = "test"

    # Digest tokens are encoded via base64 encoding algo.
    expected_token = base64.standard_b64encode(
        bytes(f"{expected_username}:{expected_password}", encoding="UTF-8"),
    )

    correct_token = secrets.compare_digest(
        bytes(incoming_token, encoding="UTF-8"),
        expected_token,
    )
    if not correct_token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect digest token",
            headers={"WWW-Authenticate": "Digest"},
        )


@app.get("/auth/", dependencies=[Depends(authorize_digest)])
def auth():
    return {"success": "true"}

You can get the token using your username and password from your shell (assuming you're using a Unix-y system):

python -c 'import base64; h = base64.urlsafe_b64encode(b"test:test"); print(h)'

This assumes that both your username and password are test. It'll print the following:

b'dGVzdDp0ZXN0'

You can use this token and send a request to this API via curl:

curl -X 'GET' 'http://localhost:5000/auth/' \
        -H 'accept: application/json' \
        -H "Authorization: Digest dGVzdDp0ZXN0" \
        -H 'Content-Type: application/json' \

This prints out the success response:

{"success":"true"}
Sign up to request clarification or add additional context in comments.

1 Comment

The header headers={"WWW-Authenticate": "Digest"} does not trigger the prompt to enter credentials on my chromium browser. I guess additional field is required to specify the algorithems and additional parameters. wikipedia.
0

Following my comment in rednafi's answer: to make the browser prompt the input of credentials in HTTP Digest Auth, two conditions have to be met.

  • The response status code is 401 Unauthorized

  • The response header contains proper WWW-Authenticate

Unfortunately headers={"WWW-Authenticate": "Digest"} is not considered "proper" enough (at least by my Edge chromium), probably because it doesn't tell the browser which algorithm for digest to use. My testing shows at least nonce and realm are required.

One can refer to the wiki for HTTP Digest Auth for details of how the digest algorithm works. I didn't find any material about using Base64 encoding in the digest as in the example test-suites posted by rednafi. MDN says the algorithm should be MD5 or one of SHA family.

Of course you are fine with it if you manually specify the Autherization headers in every request. But if you want to use the browser's prompt to input credentials, here is an working example:

from pydantic import BaseModel, ValidationError
from ..config import AppSettings, get_config
from fastapi.security import (
    HTTPDigest,
    HTTPAuthorizationCredentials,
)
from fastapi import Depends, HTTPException, Request, Security
from fastapi.responses import JSONResponse
import secrets
import base64
from hashlib import md5
from typing import Annotated

security = HTTPDigest(auto_error=False)

class HTTPDigestCredentials(BaseModel):
    username: str
    realm: str
    nonce: str
    uri: str
    response: str

    @classmethod
    def from_digest_line(cls, digest_line: str):
        """Parse the digest line and return a dict of fields"""
        cred_dict = {}

        try:
            cred_fields = [s.strip() for s in digest_line.split(",")]
            for field, value in [s.split("=", maxsplit=1) for s in cred_fields]:
                # remove quotes
                cred_dict[field] = value.strip('"')
        
            cred_obj = cls.model_validate(cred_dict)

        except (ValueError, ValidationError):
            return None
        
        return cred_obj


async def auth_admin(
    request: Request,
    credentials: Annotated[HTTPAuthorizationCredentials, Security(security)],
    config: AppSettings = Depends(get_config),
):
    """ """
    # http digest headers
    digest_params = {
        "realm": "admin-panel",
        # "qop": "auth",
        # "algorithm": "SHA-256",
        "nonce": secrets.token_hex(8),
        # "opaque": secrets.token_hex(8),
    }
    digest_line = ",".join(
        f'{key}="{value}"' for key, value in digest_params.items()
    )

    login_fail_exception = HTTPException(
        401,
        detail="Invalid authentication credentials",
        headers={"WWW-Authenticate": f'Digest {digest_line}'},
    )

    # if no Authorization header is present, credentials will be None
    if credentials is None:
        raise login_fail_exception
    
    # we use pydantic to validate the requested credential strings
    current_cred = HTTPDigestCredentials.from_digest_line(credentials.credentials)
    if current_cred is None:
        raise login_fail_exception

    # caluculate response
    # https://en.wikipedia.org/wiki/Digest_access_authentication#Overview
    admin_config = config.ADMIN_PANEL
    
    # We don't store plain passwords but HA1 instead
    # HA1 = md5(f"{admin_config.username}:{admin_config.realm}:{admin_config.password}".encode()).hexdigest()
    HA1 = admin_config.token
    HA2 = md5(f"{request.method}:{current_cred.uri}".encode()).hexdigest()
    expected_response = md5(f"{HA1}:{current_cred.nonce}:{HA2}".encode()).hexdigest()

    correct_token = secrets.compare_digest(current_cred.response, expected_response)

    if not correct_token:
        raise login_fail_exception

    return JSONResponse({"user": admin_config.username})

In this example, I used the default algorithm and qop, which is based on MD5. The first part MD5(username:realm:password) contains all credentials (you probably want to store this hash instead of plain username/password), and the second part {method}:{uri} depends on request. nonce is dynamically generated to prevent replay attacks.

The credentials extracted by HTTPAuthorizationCredentials is actually a strings with format xx="xxx", yy=yyy. I ended up processing it on my own, but I really think this should be part of the fastapi project, perhaps with a separate HTTPDigestCredentials class or similar.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.