|
| 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. |
0 commit comments