Skip to content

Commit d97aa52

Browse files
committed
initialize chapter8 secured api
1 parent 78ccbc1 commit d97aa52

12 files changed

Lines changed: 1242 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

Chapter08/secured-api/README.md

Whitespace-only changes.

Chapter08/secured-api/main.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from collections.abc import AsyncIterator
2+
from contextlib import asynccontextmanager
3+
from typing import Annotated, TypedDict
4+
5+
from fastapi import Depends, FastAPI
6+
from security.authenticator import (
7+
BaseAuthenticator,
8+
UnsafeAuthenticator,
9+
)
10+
11+
from security.commons import UserInfo
12+
from security.dependencies import GetUserWithRole, get_user
13+
from security.router import router as security_router
14+
15+
# to get a string like this run:
16+
# openssl rand -hex 32
17+
18+
19+
class State(TypedDict):
20+
authenticator: BaseAuthenticator
21+
22+
23+
@asynccontextmanager
24+
async def lifespan(_app: FastAPI) -> AsyncIterator[State]:
25+
yield {"authenticator": UnsafeAuthenticator()}
26+
27+
28+
app = FastAPI(lifespan=lifespan)
29+
app.include_router(security_router)
30+
31+
32+
@app.get("/users/me/")
33+
async def read_users_me(
34+
current_user: Annotated[UserInfo, Depends(get_user)],
35+
) -> UserInfo:
36+
return current_user
37+
38+
39+
@app.get("/users/me/premium")
40+
async def read_own_items(
41+
current_user: Annotated[
42+
UserInfo, Depends(GetUserWithRole("premium"))
43+
],
44+
):
45+
return current_user
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[project]
2+
name = "secured-api"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"fastapi[standard]>=0.135.3",
9+
"pwdlib[argon2]>=0.3.0",
10+
"pyjwt>=2.12.1",
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"ruff>=0.15.10",
16+
"ty>=0.0.29",
17+
]
18+
19+
[tool.ruff]
20+
line-length = 61
21+
22+
[tool.ruff.lint]
23+
select = ["I", "E", "F", "Q", "UP", "FAST", "ARG"]

Chapter08/secured-api/security/__init__.py

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .base import BaseAuthenticator
2+
from .unsafe import UnsafeAuthenticator
3+
4+
__all__ = ["BaseAuthenticator", "UnsafeAuthenticator"]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from abc import ABC, abstractmethod
2+
3+
from ..commons import UserInfo
4+
5+
6+
class BaseAuthenticator(ABC):
7+
@abstractmethod
8+
async def verify_user_and_password(
9+
self, username: str, password: str
10+
) -> UserInfo | None:
11+
pass
12+
13+
@abstractmethod
14+
async def generate_user_token(
15+
self, username: str, password: str
16+
) -> str:
17+
pass
18+
19+
@abstractmethod
20+
async def resolve_token(
21+
self, token: str
22+
) -> UserInfo | None:
23+
pass
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from ..commons import UserInfo
2+
from .base import BaseAuthenticator
3+
4+
5+
class UnsafeAuthenticator(BaseAuthenticator):
6+
async def verify_user_and_password(
7+
self, username: str, password: str
8+
) -> UserInfo:
9+
_ = password
10+
return UserInfo(
11+
username=username, roles={"standard", "premium"}
12+
)
13+
14+
async def generate_user_token(
15+
self, username, password
16+
) -> str:
17+
user = await self.verify_user_and_password(
18+
username, password
19+
)
20+
return f"tokenized{user.username}"
21+
22+
async def resolve_token(self, token) -> UserInfo | None:
23+
if token.startswith("tokenized"):
24+
return UserInfo(
25+
username=token.removeprefix("tokenized")
26+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Literal
2+
3+
from pydantic import BaseModel
4+
5+
Role = Literal["standard", "premium", "gold"]
6+
7+
8+
class UserInfo(BaseModel):
9+
username: str
10+
roles: set[Role] = {"standard"}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import Annotated
2+
3+
from fastapi import Depends, HTTPException, Request, status
4+
from fastapi.security import OAuth2PasswordBearer
5+
6+
from .authenticator import BaseAuthenticator
7+
from .commons import UserInfo
8+
9+
oauth2_scheme = OAuth2PasswordBearer(
10+
tokenUrl="token",
11+
scopes={
12+
"me": "Read information about the current user.",
13+
"items": "Read items.",
14+
},
15+
)
16+
17+
18+
async def get_authenticator(
19+
request: Request,
20+
) -> BaseAuthenticator:
21+
return request.state.authenticator
22+
23+
24+
async def get_user(
25+
token: Annotated[str, Depends(oauth2_scheme)],
26+
authenticator: Annotated[
27+
BaseAuthenticator, Depends(get_authenticator)
28+
],
29+
) -> UserInfo:
30+
user = await authenticator.resolve_token(token)
31+
if not user:
32+
raise HTTPException(
33+
status_code=status.HTTP_401_UNAUTHORIZED,
34+
detail="Invalid token",
35+
)
36+
return user
37+
38+
39+
class GetUserWithRole:
40+
def __init__(self, role: str):
41+
self.role = role
42+
43+
async def __call__(
44+
self, user: Annotated[UserInfo, Depends(get_user)]
45+
):
46+
if self.role not in user.roles:
47+
raise HTTPException(
48+
status_code=status.HTTP_403_FORBIDDEN,
49+
detail="Insufficient permission",
50+
)
51+
return user

0 commit comments

Comments
 (0)