Skip to content

Commit 39a64e7

Browse files
committed
Add cookbook (account-shape recipes) + identity API page; note configurable shapes in guides
1 parent 0f0a13e commit 39a64e7

23 files changed

Lines changed: 389 additions & 35 deletions

docs/api/identity.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Identity contract
2+
3+
An account's *shape* (which columns exist) is read from your model; `IdentityConfig` carries the
4+
*intent* a schema can't express, the login resolution order and the recovery factor, validated
5+
against the model when `CRUDAuth` is built so a config that contradicts the model fails at
6+
startup rather than splitting into a second source of truth.
7+
8+
- `make_auth_identity(identifiers=, recovery=, oauth=)` builds the user-column mixin for a shape;
9+
`AuthUserMixin` is its default output (email + username login, email recovery).
10+
- `IdentityConfig(login=, recovery=)` declares the intent; pass it as `CRUDAuth(identity=...)`.
11+
12+
See the [account-shape recipes](../cookbook/index.md) for end-to-end examples.
13+
14+
::: crudauth.identity.IdentityConfig
15+
16+
::: crudauth.models.mixin.make_auth_identity

docs/api/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# API Reference
22

3-
Every public symbol in crudauth, generated from the source docstrings. Unless a page notes
3+
Every public symbol in CRUDAuth, generated from the source docstrings. Unless a page notes
44
otherwise, each is importable straight from the top-level `crudauth` package.
55

66
!!! tip "New here?"
7-
Start with [Why crudauth?](../why-crudauth.md) and the [Quick Start](../#quick-start),
7+
Start with [Why CRUDAuth?](../why-crudauth.md) and the [Quick Start](../#quick-start),
88
then come back for the details.
99

1010
## Core

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Architecture
22

3-
crudauth is ports-and-adapters with feature slices and a single composition root. This page
3+
CRUDAuth is ports-and-adapters with feature slices and a single composition root. This page
44
is the map: where things live, which way imports are allowed to point, and how to add a
55
transport, OAuth provider, or backend without a cross-cutting edit.
66

docs/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
Notable changes to crudauth, newest first. crudauth is pre-1.0, so minor versions may include
3+
Notable changes to CRUDAuth, newest first. CRUDAuth is pre-1.0, so minor versions may include
44
breaking changes; those are called out explicitly.
55

66
___

docs/community/code-of-conduct.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code of Conduct
22

3-
The crudauth community follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
3+
The CRUDAuth community follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
44
In short: be respectful, assume good faith, and help keep this a welcoming place to
55
contribute regardless of who someone is.
66

docs/community/contributing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ needs to pass, the conventions the codebase follows, and how a release ships.
55

66
## Set up
77

8-
crudauth uses [uv](https://docs.astral.sh/uv/). Fork and clone the repo, then:
8+
CRUDAuth uses [uv](https://docs.astral.sh/uv/). Fork and clone the repo, then:
99

1010
```bash
1111
uv sync --all-extras --dev

docs/community/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Community
22

3-
crudauth is open source and pre-1.0. Contributions, bug reports, and questions are all
3+
CRUDAuth is open source and pre-1.0. Contributions, bug reports, and questions are all
44
welcome.
55

66
<div class="grid cards" markdown>
@@ -33,7 +33,7 @@ welcome.
3333

3434
---
3535

36-
crudauth is MIT licensed.
36+
CRUDAuth is MIT licensed.
3737

3838
[License →](license.md)
3939

docs/community/license.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# License
22

3-
crudauth is released under the **MIT License**, so you can use it in commercial and
3+
CRUDAuth is released under the **MIT License**, so you can use it in commercial and
44
open-source projects alike, with attribution.
55

66
The full text is in the repository:

docs/cookbook/email-password.md

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Email and password accounts
2+
3+
This is the account most apps start with: someone signs up with an email and a password, logs in,
4+
confirms their address, and can reset the password if they forget it. By the end of this recipe
5+
you'll have all of that running, with the security-sensitive parts (hashing, CSRF, login lockout,
6+
non-leaking responses) already handled for you.
7+
8+
It's the same starting point as [Getting started](../getting-started.md), taken all the way to
9+
verification and reset.
10+
11+
## Before you start
12+
13+
You need two things from your app: a FastAPI instance and an async SQLAlchemy session dependency.
14+
If you don't have the session dependency yet, it's the usual async setup:
15+
16+
```python title="db.py"
17+
from collections.abc import AsyncGenerator
18+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
19+
from sqlalchemy.orm import DeclarativeBase
20+
21+
engine = create_async_engine("postgresql+asyncpg://localhost/app")
22+
Session = async_sessionmaker(engine, expire_on_commit=False)
23+
24+
class Base(DeclarativeBase):
25+
pass
26+
27+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
28+
async with Session() as session:
29+
yield session
30+
```
31+
32+
CRUDAuth never opens connections itself. It borrows this dependency for every route, so it slots
33+
into whatever database wiring you already have.
34+
35+
## 1. The user model
36+
37+
CRUDAuth doesn't own your users table; it adds the columns it needs to a model you control.
38+
Inherit `AuthUserMixin` and you get the default shape: login by email or username, a password,
39+
email verification, the OAuth-linkage columns, and the timestamps and flags CRUDAuth reads. Your
40+
own columns sit right beside them.
41+
42+
```python title="models.py"
43+
from sqlalchemy.orm import Mapped, mapped_column
44+
from crudauth.models import AuthUserMixin
45+
from myapp.db import Base
46+
47+
class User(Base, AuthUserMixin):
48+
__tablename__ = "users"
49+
full_name: Mapped[str | None] = mapped_column(default=None)
50+
```
51+
52+
`AuthUserMixin` is the default output of a factory (`make_auth_identity()`); if you later want a
53+
different shape, that's the knob, but for email and password the default is exactly right. Create
54+
the table however you normally do (your migrations, or `Base.metadata.create_all` in dev).
55+
56+
## 2. Wire up CRUDAuth
57+
58+
`CRUDAuth` is the one object you configure. It needs your session dependency, your model, and a
59+
secret to sign tokens with. Mounting its router is what creates the endpoints:
60+
61+
```python title="main.py"
62+
from fastapi import FastAPI
63+
from crudauth import CRUDAuth
64+
from myapp.db import get_session
65+
from myapp.models import User
66+
67+
auth = CRUDAuth(session=get_session, user_model=User, SECRET_KEY="change-me")
68+
69+
app = FastAPI()
70+
app.include_router(auth.router) # /register, /login, /logout, /me
71+
```
72+
73+
That alone gives you working cookie-session auth: register, login, logout, and `/me`, with CSRF
74+
protection and login lockout already on. In real life the secret comes from your environment, not
75+
a string literal.
76+
77+
## 3. Add email delivery
78+
79+
Verification and reset need a way to actually send mail. CRUDAuth builds the message and the
80+
signed link; you implement an `EmailSender` that delivers it. Prefer enqueueing onto a task queue
81+
over blocking the request on SMTP:
82+
83+
```python title="main.py"
84+
from crudauth import EmailConfig, EmailSender
85+
86+
class MySender(EmailSender):
87+
async def send(self, *, to, subject, body, kind):
88+
# crudauth already built the subject and body (with the link). You deliver it.
89+
await tasks.enqueue(send_email, to=to, subject=subject, html=body)
90+
91+
auth = CRUDAuth(
92+
session=get_session, user_model=User, SECRET_KEY="change-me",
93+
email=EmailConfig(sender=MySender(), frontend_url="https://app.example.com"),
94+
)
95+
```
96+
97+
Passing `email=` is what mounts the verify, reset, and change-email routes. `frontend_url` is
98+
where the links point: your frontend reads the token out of the URL and posts it back to the
99+
matching confirm endpoint. The `kind` argument tells your sender which message it's delivering
100+
(verification, reset, and so on) so you can pick a template.
101+
102+
## 4. Register, log in, and protect a route
103+
104+
With the router mounted, the account endpoints are live:
105+
106+
```bash
107+
# create an account
108+
curl -X POST http://localhost:8000/register -H "Content-Type: application/json" \
109+
-d '{"email":"alice@example.com","username":"alice","password":"a-strong-one"}'
110+
111+
# log in by email OR username; -c saves the session + CSRF cookies to a jar
112+
curl -X POST http://localhost:8000/login -c jar.txt -d "username=alice@example.com&password=a-strong-one"
113+
114+
# the built-in "who am I"
115+
curl http://localhost:8000/me -b jar.txt
116+
```
117+
118+
The `username` form field accepts either the email or the username; CRUDAuth resolves whichever
119+
matches. Protecting your own routes is one dependency:
120+
121+
```python
122+
from fastapi import Depends
123+
from crudauth import Principal
124+
125+
@app.get("/dashboard")
126+
async def dashboard(user: Principal = Depends(auth.current_user())):
127+
return {"id": user.user_id}
128+
```
129+
130+
`current_user()` authenticates the request and hands your handler a `Principal`: the user's id,
131+
their flags, and the loaded row. If the request isn't logged in it never reaches your code; it
132+
gets a 401.
133+
134+
## 5. Email verification
135+
136+
Verification is two steps, request then confirm. The request endpoint returns the same response
137+
whether or not the address exists, so it can't be used to probe who has an account:
138+
139+
```bash
140+
curl -X POST http://localhost:8000/email/verify-request \
141+
-H "Content-Type: application/json" -d '{"email":"alice@example.com"}'
142+
```
143+
144+
CRUDAuth signs a single-use, time-limited token and hands it to your sender inside a link to
145+
`frontend_url`. Your page reads the `token` query parameter and posts it back:
146+
147+
```bash
148+
curl -X POST http://localhost:8000/email/verify-confirm \
149+
-H "Content-Type: application/json" -d '{"token":"eyJ..."}'
150+
```
151+
152+
That marks the address verified. To require a confirmed address on a route, add the gate:
153+
154+
```python
155+
@app.get("/billing")
156+
async def billing(user: Principal = Depends(auth.current_user(verified=True))):
157+
...
158+
```
159+
160+
## 6. Password reset
161+
162+
Reset is the same request/confirm shape, and CRUDAuth treats it as attacker eviction: a
163+
successful reset bumps the user's token version and terminates their other sessions, so a leaked
164+
session or bearer token dies with the reset.
165+
166+
```bash
167+
curl -X POST http://localhost:8000/password/reset-request \
168+
-H "Content-Type: application/json" -d '{"email":"alice@example.com"}'
169+
170+
# the user clicks the link; your reset page posts the token + the new password:
171+
curl -X POST http://localhost:8000/password/reset-confirm \
172+
-H "Content-Type: application/json" \
173+
-d '{"token":"eyJ...","new_password":"a-new-strong-one"}'
174+
```
175+
176+
## What you got for free
177+
178+
Look at everything you didn't have to write: passwords are bcrypt-hashed, the login error is
179+
uniform and constant-time (so it can't reveal which accounts exist), repeated failures trip an
180+
escalating lockout, session cookies carry a CSRF token that mutations must echo back, and the
181+
verify and reset request endpoints don't leak existence. That's the whole point of the default
182+
shape: the safe behavior is what you get by saying nothing.
183+
184+
## Where to go next
185+
186+
- Send verification and reset over SMS too, or instead: [delivery channels](../guides/accounts/email.md#delivery-channels).
187+
- No email at all: [Username-only accounts](username-only.md).
188+
- Add "Sign in with Google": the [OAuth guide](../guides/auth/oauth.md).
189+
- Set app columns at signup (a default tier, a derived name): [registration](../guides/accounts/registration.md#setting-columns-the-server-controls).
190+
- Going to production: [storage and lifespan](../guides/infra/storage.md) covers Redis and multiple workers.

docs/cookbook/index.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Cookbook
2+
3+
Complete, from-scratch recipes for a goal. Where the [Guides](../guides/index.md) document one
4+
feature at a time and assume the base setup, each recipe here builds a working setup end to end,
5+
so you can copy one and have the account shape you want.
6+
7+
<div class="grid cards" markdown>
8+
9+
- **[Email + password](email-password.md)**
10+
11+
---
12+
13+
The default shape: email/username login, password, verification, and reset, wired end to end.
14+
15+
[Read →](email-password.md)
16+
17+
- **[Username-only accounts](username-only.md)**
18+
19+
---
20+
21+
No email anywhere: log in by username, no recovery, no verification. For throwaway or
22+
internal accounts.
23+
24+
[Read →](username-only.md)
25+
26+
</div>
27+
28+
**Prerequisites:** a FastAPI app and an async SQLAlchemy 2.0 session dependency. Each recipe
29+
shows everything else from scratch.

0 commit comments

Comments
 (0)