Notes, accounts, and ownership
The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments.
Follow one create request
The browser posts JSON to /api/notes with an Authorization: Bearer … header. The @protected(database) decorator checks the token before the handler runs and sets request.user and request.login_id.
read_note() rejects unknown fields and validates the title, body, and pinned flag. The handler generates an ID and timestamps, adds the owner from the authenticated user, and calls database.create_note(). A successful create returns JSON 201 with a Location header.
The owner comes from the authenticated user, never from a client-supplied owner field. Otherwise, congratulations: you have built an impersonation API.
@app.route('/api/notes', methods=['POST'])
@protected(database)
async def create_note(request):
try:
data = await read_note(request)
except ValueError as error:
return {'error': str(error)}, 'application/json', 400
note_id = token_urlsafe(9)
now = datetime.now(UTC).isoformat(timespec='microseconds')
note = {
'id': note_id,
'owner': request.user['id'],
**data,
'created_at': now,
'updated_at': now,
}
await database.create_note(note)
return (
note,
'application/json',
201,
[('Location', f'/api/notes/{note_id}')],
)The notebook’s API
| Method and path | Behavior |
|---|---|
POST /api/signup | Create an account; 201, or 409 for an existing username. |
POST /api/login | Return an access token and set the refresh cookie. |
POST /api/refresh | Use the refresh cookie to issue another access token. |
GET /api/me | Return the authenticated user. |
POST /api/logout | Revoke the login and expire its refresh cookie. |
GET / POST /api/notes | List private notes or create a note. |
GET / PUT / PATCH / DELETE /api/notes/{note_id} | Read, replace editable fields, partially update, or delete a note. |
GET /api/notes/export | Download the authenticated user’s notes as JSON. |
The fixed /api/notes/export route wins over /api/notes/{note_id}, even though it is registered afterward. Listing accepts q, pinned=true|false, limit from 1 to 50, and a nonnegative offset.
Access tokens and refresh cookies
Passwords use PBKDF2-HMAC-SHA256 with a random salt and 600,000 iterations. Hashing runs through asyncio.to_thread() so the event loop can keep serving other requests. Unknown usernames still perform a dummy hash.
Access and refresh tokens are random opaque strings. MongoDB stores SHA-256 digests of those tokens. Access tokens last one hour; the login’s refresh window lasts seven days. The database checks expiry during authentication instead of waiting for MongoDB’s TTL cleanup to get around to it.
The browser keeps the access token in memory. The refresh token lives in an HttpOnly, SameSite=Strict cookie scoped to /api, with Secure added when the ASGI scheme is HTTPS. Refresh reuses the cookie while issuing new access tokens; it does not rotate the refresh token on each call.
MongoDB stores each login and its access credentials in one document. Logout deletes that document, revoking the login’s access tokens as well. The browser’s renewLogin() shares one refresh promise across concurrent requests and refuses to replay an old account’s pending request under a newly logged-in account.
Try the auth flow with curl
With the notebook running, use a separate shell. The password here is demonstration data. Use something you haven’t published in a tutorial for your real account.
curl -sS http://127.0.0.1:8000/api/signup \
-H 'Content-Type: application/json' \
-d '{"username":"ada","password":"a sufficiently long example password"}'
curl -sS -c notebook.cookies http://127.0.0.1:8000/api/login \
-H 'Content-Type: application/json' \
-H 'X-Notebook-Request: 1' \
-d '{"username":"ada","password":"a sufficiently long example password"}'Copy the access_token from the login JSON into a shell variable, then create and list notes:
read -r -s NOTEBOOK_TOKEN
# Paste the access token, then press Enter.
curl -i http://127.0.0.1:8000/api/notes \
-H "Authorization: Bearer $NOTEBOOK_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Ship it","body":"After reading the deployment chapter.","pinned":true}'
curl -sS 'http://127.0.0.1:8000/api/notes?limit=5&offset=0' \
-H "Authorization: Bearer $NOTEBOOK_TOKEN"
curl -sS -b notebook.cookies -X POST http://127.0.0.1:8000/api/refresh \
-H 'X-Notebook-Request: 1'Login and refresh require X-Notebook-Request: 1. Browser requests also face the app’s same-origin checks. A cookie jar contains credentials; keep it private and remove it when you finish.
Check ownership where the data changes
List and export operations filter by owner. Reading another user’s existing note returns 403; a missing ID returns 404. Updates and deletes include both the note ID and owner in the MongoDB write filter, so an earlier ownership check isn’t the sole line of defense.
The application limits titles to 100 characters and bodies to 10,000 characters. PATCH preserves omitted fields; PUT requires a title and fills omitted body and pinned values with their defaults. Both reject fields outside title, body, and pinned.
Know what is local to a worker
The rate limiter stores timestamps in memory per application instance. A client can get three successful calls to the demo endpoint per ten-second window per worker. Multiple workers don’t magically share a Python dict, however enthusiastically you deploy them.
Database-backed sessions survive app restarts; rate-limit buckets do not. For shared throttling, use a gateway or a shared store. Before deploying an adapted auth system, decide how you will handle account recovery, token abuse, CSRF, and operational monitoring. The notebook demonstrates a design; it isn’t an identity provider hiding in a trench coat.
