diff --git a/.claude/skills/fastapi-backend/SKILL.md b/.claude/skills/fastapi-backend/SKILL.md index c9f5de5..159ebe9 100644 --- a/.claude/skills/fastapi-backend/SKILL.md +++ b/.claude/skills/fastapi-backend/SKILL.md @@ -26,6 +26,15 @@ grep -rnE "^(from|import) fastapi" src/milo_backend/service/ # must be empty `schemas` are wire shapes, `model` are persistence shapes. They are allowed to look identical and must still be two files, because the day they diverge you do not want to be rewriting routes. +Input normalisation belongs in `schemas`, never halfway down a route or a service. `schemas/auth.py` +defines the annotated types `Name`, `NormalisedEmail` and `OtpCode`, so every endpoint taking an +email trims and lowercases it, a name arrives with control characters gone and runs of whitespace +collapsed, and a code pasted as `123 456` arrives as `123456`. Reuse those aliases rather than +re-declaring `EmailStr` on a new model, or two endpoints will disagree about what the same address +is. `Password` is deliberately **not** stripped: trimming a password silently changes the credential +someone typed. `_normalise()` stays in the service as the domain rule, since a service must be +correct when called from a test that never went through a schema. + ## core is a private package with a public front door Every module in `core` is underscore-prefixed and re-exported through `core/__init__.py` with an @@ -38,9 +47,23 @@ import block and `__all__`, keep that list alphabetical, ruff's isort rules will `get_settings()` is `@lru_cache`d. Never construct `Settings()` in application code, and never read `os.environ` directly. -Functions that need config take `settings: Settings | None = None` and fall back to `get_settings()` -inside the body. `_database.py`, `_cookies.py` and `_rate_limit.py` all do this. It is the seam that -lets a test pass a hand-built `Settings` without touching the cache. + +Also remember that all the variables we need need to add in settings and access from setting only. If any variable is not environment variable put that as a value in settings. + +A function takes config one of two ways, never both. `settings: Settings | None = None` with a +`settings or get_settings()` fallback is banned: it advertises an optional argument that only tests +ever pass, and leaves two names for one value in every body. + +1. **Required keyword argument**, `*, settings: Settings`, whenever our own code calls the function. + `set_auth_cookies`, `clear_auth_cookies` and `connect` are the examples. The function stays pure, + a test builds a `Settings(...)` and passes it, and nothing has to touch the `lru_cache`. Routes + receive it through `Depends(get_settings)` rather than plumbing it by hand. +2. **No parameter at all**, calling `get_settings()` in the body, only when option 1 is impossible: + the signature belongs to a framework (`client_key(request)` is invoked by slowapi), the call + happens at import (`build_limiter()`), or the function is already a global accessor and purity is + unreachable (`get_database()` reads the module-global client). + +When in doubt, option 1. Secrets are `SecretStr` and are read through the `jwt_secret` and `ip_salt` properties, which raise when unset. Do not call `.get_secret_value()` at a call site. @@ -77,11 +100,19 @@ Every DAO method is `async` and awaited. Motor returns cursors, not lists, `awai with an explicit bound rather than an unbounded one. Indexes belong in `dao`, declared next to the queries that need them. -**Known drift to fix when you next touch it:** `dao/_base.py` types `db` as pymongo's `AsyncDatabase` -while `core/_database.get_database()` returns Motor's `AsyncIOMotorDatabase`. Motor is the declared -dependency, so make `_base.py` match `core`. That file also lacks `from __future__ import -annotations`, carries an unused `get_database` import and an untyped `collection` property, and has -not been through `make fmt-py`. +`dao/_base.py` types `db` and `collection` as Motor's `AsyncIOMotorDatabase` and +`AsyncIOMotorCollection`, matching what `core.get_database()` hands it. `dao/auth.py` is the +reference pair: `UserDAO` owns the unique `email` index, `EmailVerificationDAO` owns a unique +`user_id` index plus a TTL index on `last_sent_at`, so a spent code deletes itself rather than +lingering as a guessable row. Indexes are created once at startup by `ensure_indexes(settings=...)` +in `dependencies/`, called from the lifespan handler. + +A verification row stores `user_id` and nothing that the `users` document already answers: no email +copy, and no `expires_at`, since expiry is `last_sent_at + OTP_EXPIRE_MINUTES`. The TTL window is +that same expiry plus `OTP_RECORD_GRACE_SECONDS`, so a code submitted a little late still returns +`verification_code_expired` rather than a confusing `verification_not_found`. `expireAfterSeconds` +is baked into the index, so `create_indexes` catches Mongo's `IndexOptionsConflict` (85) and rebuilds +the index when that setting changes. ## Errors @@ -101,8 +132,13 @@ message reach the client. Handlers are registered most-specific first in `regist `limiter` is built once at import from `get_settings()`, so a test that changes rate-limit settings must call `get_settings.cache_clear()` and rebuild via `build_limiter()`. -A route decorated with `@limiter.limit(...)` **must** take `request: Request` in its signature or -slowapi raises at call time. `SlowAPIMiddleware` is only added when `rate_limit_enabled`, and the +A route decorated with `@limiter.limit(...)` **must** take both `request: Request` and +`response: Response` in its signature. The first is how slowapi finds the client key, the second is +where it writes the `X-RateLimit-*` headers, and because `headers_enabled=True` a missing `response` +raises `parameter 'response' must be an instance of starlette.responses.Response` on the first call, +not at import. The limit string may be a callable, so +`@limiter.limit(lambda: get_settings().rate_limit_login)` keeps the number in `Settings` instead of +freezing it at import. `SlowAPIMiddleware` is only added when `rate_limit_enabled`, and the `RateLimitExceeded` handler re-injects headers from `request.state.view_rate_limit`. `client_key` hashes salted IP + current date and keeps 24 hex characters. That digest is a @@ -130,18 +166,33 @@ forbidden field, do not add a nullable column "for later". ## Current state -Scaffolded and empty: `dao/auth.py`, `routes/auth.py`, `dependencies/service.py`, `service/`, -`model/`, and `schemas/auth.py` (bare `BaseModel` import). `dependencies/` has **no `__init__.py`**, -add one when you first import from it. +Auth is implemented end to end and is the worked example for every layer: `routes/auth.py` -> +`service/auth.py` -> `dao/auth.py`, assembled in `dependencies/service.py`, mounted in `create_app()`. + +``` +POST /auth/register 201, creates the user unverified and emails a code +POST /auth/verify-email/start 202, resends, subject to the cooldown +POST /auth/verify-email/status 200, is a code still live, for how long, resend in how long +POST /auth/verify-otp 200, verifies, then sets both cookies +POST /auth/login 200, or 403 email_not_verified +POST /auth/refresh 200, from the refresh cookie +POST /auth/logout 204 +GET /auth/me 200, through the CurrentUser dependency +``` + +Codes are six digits, HMAC-SHA256 hashed with `OTP_HASH_SECRET` before they reach Mongo, valid ten +minutes, five attempts, one send per minute. Passwords are argon2id. Tokens are PyJWT HS256 carrying +a `type` claim that `decode_token` checks, so a refresh token cannot be replayed as an access token, +and they only ever travel in httpOnly cookies. -No router is mounted in `app.py` yet. Mounting is `app.include_router(...)` inside `create_app()`, -after `register_exception_handlers`. +The dependency set is fastapi, uvicorn, motor, pydantic-settings, slowapi, pyjwt, argon2-cffi, +aiosmtplib and email-validator, all permissive. Milo ships under Elastic License 2.0, so any new +dependency must be redistributable: prefer MIT, Apache 2.0, BSD, ISC, and raise GPL or AGPL before +adding it. -`Settings` describes JWTs, but **no JWT or password-hashing library is installed**. The dependency -set is fastapi, uvicorn, motor, pydantic-settings, slowapi. Implementing auth means adding one -(PyJWT and argon2-cffi are both permissive-licensed and fine). Milo ships under Elastic License 2.0, -so any new dependency must be redistributable: prefer MIT, Apache 2.0, BSD, ISC, and raise GPL or -AGPL before adding it. +Still missing: `tests/` holds only `__init__.py`, there is no CSRF defence for the cross-site cookie +case, `/auth/verify-email/start` answers differently for known and unknown addresses so it leaks +membership, and nothing beyond auth exists yet, no resumes, tracking links or events. `pyproject.toml` has no `[tool.ruff]`, `[tool.mypy]` or `[tool.pytest.ini_options]` section, so all three run on defaults. `make typecheck` runs plain `mypy src`, which is not strict mode, do not diff --git a/package-lock.json b/package-lock.json index ceef8e3..8b70677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2553,6 +2553,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2780,6 +2792,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2806,6 +2824,18 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2969,7 +2999,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3078,6 +3107,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3178,7 +3219,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3235,6 +3275,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3262,7 +3311,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3393,7 +3441,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3403,7 +3450,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3441,7 +3487,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3454,7 +3499,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4061,6 +4105,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4077,11 +4141,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4145,7 +4224,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4170,7 +4248,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4258,7 +4335,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4330,7 +4406,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4343,7 +4418,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4359,7 +4433,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4385,6 +4458,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5421,7 +5507,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5464,6 +5549,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5550,7 +5656,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6119,6 +6224,15 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7451,6 +7565,7 @@ "@milo/styles": "*", "@milo/ui": "*", "@react-three/fiber": "^9.7.0", + "axios": "^1.20.0", "clsx": "^2.1.1", "lucide-react": "^0.545.0", "next": "16.3.0", diff --git a/packages/backend/poetry.lock b/packages/backend/poetry.lock index 0f47cfe..9ec777b 100644 --- a/packages/backend/poetry.lock +++ b/packages/backend/poetry.lock @@ -1,5 +1,21 @@ # This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +[[package]] +name = "aiosmtplib" +version = "4.0.2" +description = "asyncio SMTP client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742"}, + {file = "aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"] +uvloop = ["uvloop (>=0.18)"] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -43,6 +59,78 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.32.0)"] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, + {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e"}, + {file = "argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638"}, + {file = "argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8"}, + {file = "argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4"}, + {file = "argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440"}, + {file = "argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7014ab7e6f5d8511af92544667a0346ea6dfc314ea9a7cad1dba9fdb5c9a6e33"}, + {file = "argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:242bb0cda2ae3650764fc194593d9ea45fc9e72729acd89778c7cfe184cec2a5"}, + {file = "argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b70225b5fd1e0d2ef4f7fd30d24658454535f0924dff0caca5dc08efbbbadfbb"}, + {file = "argon2_cffi_bindings-26.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1af817e84578ef8b7295ad17de0f9896e4c8520dbf2233c7aa5aa3d487256fc4"}, + {file = "argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8"}, + {file = "argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a"}, + {file = "argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba"}, + {file = "argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e"}, + {file = "argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.0.1", markers = "python_version < \"3.14\""}, + {version = ">=2", markers = "python_version >= \"3.14\""}, +] + [[package]] name = "ast-serialize" version = "0.8.0" @@ -122,6 +210,119 @@ files = [ {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "click" version = "8.4.2" @@ -189,6 +390,22 @@ idna = ["idna (>=3.10)"] trio = ["trio (>=0.30)"] wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "fastapi" version = "0.141.1" @@ -689,6 +906,19 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -883,6 +1113,21 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.14.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, + {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + [[package]] name = "pymongo" version = "4.17.0" @@ -1643,4 +1888,4 @@ dev = ["pytest", "setuptools"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "6e3c0958f8281c32c3b1b106aad366134bbd3b9df4391ce1dd6276f07f1fda17" +content-hash = "ebb5071223d348b3e027b28ad12be2000d8d903eddee229fdeba1f385bea11c4" diff --git a/packages/backend/pyproject.toml b/packages/backend/pyproject.toml index 2609cdf..a131f6c 100644 --- a/packages/backend/pyproject.toml +++ b/packages/backend/pyproject.toml @@ -15,7 +15,11 @@ dependencies = [ "uvicorn[standard] (>=0.52.3,<0.53.0)", "motor (>=3.7.1,<4.0.0)", "pydantic-settings (>=2.15.0,<3.0.0)", - "slowapi (>=0.1.10,<0.2.0)" + "slowapi (>=0.1.10,<0.2.0)", + "pyjwt (>=2.10,<3.0)", + "argon2-cffi (>=25.1,<26.0)", + "aiosmtplib (>=4.0,<5.0)", + "email-validator (>=2.3,<3.0)" ] [tool.poetry] diff --git a/packages/backend/src/milo_backend/app.py b/packages/backend/src/milo_backend/app.py index 92910d6..28724ca 100644 --- a/packages/backend/src/milo_backend/app.py +++ b/packages/backend/src/milo_backend/app.py @@ -8,12 +8,16 @@ from slowapi.middleware import SlowAPIMiddleware from milo_backend.core import connect, disconnect, get_settings, limiter +from milo_backend.dependencies import ensure_indexes from milo_backend.exception.handlers import register_exception_handlers +from milo_backend.routes import auth_router @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None,None]: - await connect() +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + settings = get_settings() + await connect(settings) + await ensure_indexes(settings=settings) try: yield finally: @@ -48,6 +52,8 @@ def create_app() -> FastAPI: register_exception_handlers(app) + app.include_router(auth_router) + return app diff --git a/packages/backend/src/milo_backend/core/__init__.py b/packages/backend/src/milo_backend/core/__init__.py index efa55cb..f2db428 100644 --- a/packages/backend/src/milo_backend/core/__init__.py +++ b/packages/backend/src/milo_backend/core/__init__.py @@ -5,22 +5,51 @@ set_auth_cookies, ) from milo_backend.core._database import connect, disconnect, get_client, get_database +from milo_backend.core._email import send_email from milo_backend.core._rate_limit import client_key, limiter -from milo_backend.core._settings import ENV_FILES, Environment, Settings, get_settings +from milo_backend.core._security import ( + TokenType, + create_token, + decode_token, + generate_otp, + hash_otp, + hash_password, + password_needs_rehash, + verify_otp, + verify_password, +) +from milo_backend.core._settings import ( + ENV_FILES, + EmailDelivery, + Environment, + Settings, + get_settings, +) __all__ = [ "ACCESS_TOKEN_COOKIE", "ENV_FILES", "REFRESH_TOKEN_COOKIE", + "EmailDelivery", "Environment", "Settings", + "TokenType", "clear_auth_cookies", "client_key", "connect", + "create_token", + "decode_token", "disconnect", + "generate_otp", "get_client", "get_database", "get_settings", + "hash_otp", + "hash_password", "limiter", + "password_needs_rehash", + "send_email", "set_auth_cookies", + "verify_otp", + "verify_password", ] diff --git a/packages/backend/src/milo_backend/core/_cookies.py b/packages/backend/src/milo_backend/core/_cookies.py index bc979a0..d9e33cc 100644 --- a/packages/backend/src/milo_backend/core/_cookies.py +++ b/packages/backend/src/milo_backend/core/_cookies.py @@ -2,7 +2,7 @@ from starlette.responses import Response -from milo_backend.core._settings import Settings, get_settings +from milo_backend.core._settings import Settings ACCESS_TOKEN_COOKIE = "milo_access_token" REFRESH_TOKEN_COOKIE = "milo_refresh_token" @@ -13,31 +13,29 @@ def set_auth_cookies( *, access_token: str, refresh_token: str, - settings: Settings | None = None, + settings: Settings, refresh_path: str = "/", ) -> Response: - config = settings or get_settings() - response.set_cookie( key=ACCESS_TOKEN_COOKIE, value=access_token, - max_age=int(config.access_token_expiry.total_seconds()), + max_age=int(settings.access_token_expiry.total_seconds()), path="/", - domain=config.cookie_domain, - httponly=config.cookie_httponly, - secure=bool(config.cookie_secure), - samesite=config.cookie_samesite, + domain=settings.cookie_domain, + httponly=settings.cookie_httponly, + secure=bool(settings.cookie_secure), + samesite=settings.cookie_samesite, ) response.set_cookie( key=REFRESH_TOKEN_COOKIE, value=refresh_token, - max_age=int(config.refresh_token_expiry.total_seconds()), + max_age=int(settings.refresh_token_expiry.total_seconds()), path=refresh_path, - domain=config.cookie_domain, - httponly=config.cookie_httponly, - secure=bool(config.cookie_secure), - samesite=config.cookie_samesite, + domain=settings.cookie_domain, + httponly=settings.cookie_httponly, + secure=bool(settings.cookie_secure), + samesite=settings.cookie_samesite, ) return response @@ -46,19 +44,17 @@ def set_auth_cookies( def clear_auth_cookies( response: Response, *, - settings: Settings | None = None, + settings: Settings, refresh_path: str = "/", ) -> Response: - config = settings or get_settings() - for key, path in ((ACCESS_TOKEN_COOKIE, "/"), (REFRESH_TOKEN_COOKIE, refresh_path)): response.delete_cookie( key=key, path=path, - domain=config.cookie_domain, - httponly=config.cookie_httponly, - secure=bool(config.cookie_secure), - samesite=config.cookie_samesite, + domain=settings.cookie_domain, + httponly=settings.cookie_httponly, + secure=bool(settings.cookie_secure), + samesite=settings.cookie_samesite, ) return response diff --git a/packages/backend/src/milo_backend/core/_database.py b/packages/backend/src/milo_backend/core/_database.py index 606a709..1d3e61e 100644 --- a/packages/backend/src/milo_backend/core/_database.py +++ b/packages/backend/src/milo_backend/core/_database.py @@ -12,20 +12,19 @@ _client: AsyncIOMotorClient[dict[str, Any]] | None = None -async def connect(settings: Settings | None = None) -> None: +async def connect(settings: Settings) -> None: global _client - config = settings or get_settings() _client = AsyncIOMotorClient( - config.mongodb_uri, - serverSelectionTimeoutMS=config.mongodb_timeout_ms, - connectTimeoutMS=config.mongodb_timeout_ms, + settings.mongodb_uri, + serverSelectionTimeoutMS=settings.mongodb_timeout_ms, + connectTimeoutMS=settings.mongodb_timeout_ms, maxPoolSize=10, retryWrites=True, ) await _client.admin.command("ping") - logger.info("Connected to MongoDB database %s", config.mongodb_db) + logger.info("Connected to MongoDB database %s", settings.mongodb_db) async def disconnect() -> None: @@ -43,8 +42,6 @@ def get_client() -> AsyncIOMotorClient[dict[str, Any]]: return _client -def get_database( - settings: Settings | None = None, -) -> AsyncIOMotorDatabase[dict[str, Any]]: - config = settings or get_settings() - return get_client()[config.mongodb_db] +def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]: + settings = get_settings() + return get_client()[settings.mongodb_db] diff --git a/packages/backend/src/milo_backend/core/_email.py b/packages/backend/src/milo_backend/core/_email.py new file mode 100644 index 0000000..b7abdad --- /dev/null +++ b/packages/backend/src/milo_backend/core/_email.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +from email.message import EmailMessage + +import aiosmtplib + +from milo_backend.core._settings import EmailDelivery, Settings + +logger = logging.getLogger("milo.email") + + +def build_message( + *, + to: str, + subject: str, + text_body: str, + html_body: str | None, + settings: Settings, +) -> EmailMessage: + message = EmailMessage() + message["From"] = settings.email_sender + message["To"] = to + message["Subject"] = subject + message.set_content(text_body) + + if html_body is not None: + message.add_alternative(html_body, subtype="html") + + return message + + +async def send_email( + *, + to: str, + subject: str, + text_body: str, + html_body: str | None = None, + settings: Settings, +) -> None: + message = build_message( + to=to, + subject=subject, + text_body=text_body, + html_body=html_body, + settings=settings, + ) + + if settings.email_delivery is EmailDelivery.CONSOLE: + logger.info("Email not sent, delivery is console.\n%s", message.as_string()) + return + + credentials = settings.smtp_credentials + + await aiosmtplib.send( + message, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=credentials[0] if credentials else None, + password=credentials[1] if credentials else None, + start_tls=bool(settings.smtp_start_tls), + use_tls=settings.smtp_use_tls, + timeout=settings.smtp_timeout_seconds, + ) + + logger.info("Delivered %r via %s", subject, settings.smtp_host) diff --git a/packages/backend/src/milo_backend/core/_rate_limit.py b/packages/backend/src/milo_backend/core/_rate_limit.py index cb0cb29..9fb86df 100644 --- a/packages/backend/src/milo_backend/core/_rate_limit.py +++ b/packages/backend/src/milo_backend/core/_rate_limit.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +from collections.abc import Callable from datetime import UTC, datetime from slowapi import Limiter @@ -28,7 +29,9 @@ def client_key(request: Request) -> str: def build_limiter() -> Limiter: settings = get_settings() - defaults = [settings.rate_limit_default] if settings.rate_limit_default else [] + defaults: list[str | Callable[..., str]] = ( + [settings.rate_limit_default] if settings.rate_limit_default else [] + ) return Limiter( key_func=client_key, diff --git a/packages/backend/src/milo_backend/core/_security.py b/packages/backend/src/milo_backend/core/_security.py new file mode 100644 index 0000000..d4b9fcf --- /dev/null +++ b/packages/backend/src/milo_backend/core/_security.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +import jwt +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +from milo_backend.core._settings import Settings + +TokenType = Literal["access", "refresh"] + +_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def password_needs_rehash(password_hash: str) -> bool: + try: + return _hasher.check_needs_rehash(password_hash) + except InvalidHashError: + return True + + +def generate_otp(*, settings: Settings) -> str: + return str(secrets.randbelow(10**settings.otp_length)).zfill(settings.otp_length) + + +def hash_otp(code: str, *, settings: Settings) -> str: + return hmac.new( + settings.otp_secret.encode(), code.encode(), hashlib.sha256 + ).hexdigest() + + +def verify_otp(code: str, code_hash: str, *, settings: Settings) -> bool: + return hmac.compare_digest(hash_otp(code, settings=settings), code_hash) + + +def create_token( + *, + subject: str, + token_type: TokenType, + expires_in: timedelta, + settings: Settings, +) -> str: + issued_at = datetime.now(UTC) + payload = { + "sub": subject, + "type": token_type, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "iat": issued_at, + "exp": issued_at + expires_in, + "jti": uuid.uuid4().hex, + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_token( + token: str, *, expected_type: TokenType, settings: Settings +) -> dict[str, Any]: + payload: dict[str, Any] = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + audience=settings.jwt_audience, + issuer=settings.jwt_issuer, + options={"require": ["exp", "iat", "sub"]}, + ) + + if payload.get("type") != expected_type: + raise jwt.InvalidTokenError(f"Expected a {expected_type} token.") + + return payload diff --git a/packages/backend/src/milo_backend/core/_settings.py b/packages/backend/src/milo_backend/core/_settings.py index d7af5ef..44ca979 100644 --- a/packages/backend/src/milo_backend/core/_settings.py +++ b/packages/backend/src/milo_backend/core/_settings.py @@ -6,13 +6,15 @@ from functools import lru_cache from typing import Annotated, Literal -from pydantic import Field, SecretStr, field_validator, model_validator +from pydantic import EmailStr, Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict ENV_FILES = (".env", ".env.development", ".env.local") MIN_SECRET_LENGTH = 32 +LOCAL_SMTP_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "mailpit", "mailhog"}) + class Environment(StrEnum): DEVELOPMENT = "development" @@ -20,6 +22,11 @@ class Environment(StrEnum): PRODUCTION = "production" +class EmailDelivery(StrEnum): + SMTP = "smtp" + CONSOLE = "console" + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=ENV_FILES, @@ -36,9 +43,33 @@ class Settings(BaseSettings): jwt_secret_key: SecretStr | None = None jwt_algorithm: Literal["HS256", "HS384", "HS512"] = "HS256" + jwt_issuer: str = "milo" + jwt_audience: str = "milo-app" access_token_expire_minutes: int = Field(default=15, ge=1, le=1_440) refresh_token_expire_days: int = Field(default=30, ge=1, le=365) + password_min_length: int = Field(default=12, ge=8, le=128) + password_max_length: int = Field(default=128, ge=64, le=1_024) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=1025, ge=1, le=65_535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_start_tls: bool | None = None + smtp_use_tls: bool = False + smtp_timeout_seconds: int = Field(default=10, ge=1, le=120) + + email_delivery: EmailDelivery | None = None + email_from_address: EmailStr = "no-reply@milo.app" + email_from_name: str = "Milo" + + otp_length: int = Field(default=6, ge=4, le=10) + otp_expire_minutes: int = Field(default=10, ge=1, le=60) + otp_max_attempts: int = Field(default=5, ge=1, le=20) + otp_resend_cooldown_seconds: int = Field(default=60, ge=0, le=3_600) + otp_record_grace_seconds: int = Field(default=300, ge=0, le=3_600) + otp_hash_secret: SecretStr | None = None + cors_origins: Annotated[list[str], NoDecode] = Field( default_factory=lambda: ["http://localhost:3001"] ) @@ -51,6 +82,11 @@ class Settings(BaseSettings): rate_limit_enabled: bool = True rate_limit_default: str | None = "200/minute" + rate_limit_register: str = "5/hour" + rate_limit_login: str = "10/minute" + rate_limit_verify_otp: str = "10/minute" + rate_limit_resend_otp: str = "5/hour" + rate_limit_verification_status: str = "30/minute" ip_hash_salt: SecretStr | None = None @property @@ -65,6 +101,18 @@ def access_token_expiry(self) -> timedelta: def refresh_token_expiry(self) -> timedelta: return timedelta(days=self.refresh_token_expire_days) + @property + def otp_expiry(self) -> timedelta: + return timedelta(minutes=self.otp_expire_minutes) + + @property + def otp_resend_cooldown(self) -> timedelta: + return timedelta(seconds=self.otp_resend_cooldown_seconds) + + @property + def otp_record_ttl_seconds(self) -> int: + return self.otp_expire_minutes * 60 + self.otp_record_grace_seconds + @property def ip_salt(self) -> str: if self.ip_hash_salt is None: @@ -77,6 +125,22 @@ def jwt_secret(self) -> str: raise RuntimeError("JWT_SECRET_KEY is not configured.") return self.jwt_secret_key.get_secret_value() + @property + def otp_secret(self) -> str: + if self.otp_hash_secret is None: + raise RuntimeError("OTP_HASH_SECRET is not configured.") + return self.otp_hash_secret.get_secret_value() + + @property + def smtp_credentials(self) -> tuple[str, str] | None: + if self.smtp_username is None or self.smtp_password is None: + return None + return self.smtp_username, self.smtp_password.get_secret_value() + + @property + def email_sender(self) -> str: + return f"{self.email_from_name} <{self.email_from_address}>" + @field_validator("cors_origins", mode="before") @classmethod def _split_origins(cls, value: object) -> object: @@ -92,6 +156,14 @@ def _apply_environment_defaults(self) -> Settings: if self.cookie_samesite is None: self.cookie_samesite = "none" if self.is_production else "lax" + if self.smtp_start_tls is None: + self.smtp_start_tls = self.is_production + + if self.email_delivery is None: + self.email_delivery = ( + EmailDelivery.SMTP if self.is_production else EmailDelivery.CONSOLE + ) + if self.ip_hash_salt is None: self.ip_hash_salt = SecretStr(secrets.token_urlsafe(32)) @@ -103,6 +175,15 @@ def _apply_environment_defaults(self) -> Settings: ) self.jwt_secret_key = SecretStr(secrets.token_urlsafe(48)) + if self.otp_hash_secret is None: + if self.is_production: + raise ValueError( + "OTP_HASH_SECRET must be set in production. Without it every worker " + "hashes codes differently and verification fails at random. " + "Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'" + ) + self.otp_hash_secret = SecretStr(secrets.token_urlsafe(48)) + return self @model_validator(mode="after") @@ -116,6 +197,15 @@ def _reject_unsafe_combinations(self) -> Settings: if self.cookie_samesite == "none" and not self.cookie_secure: raise ValueError("cookie_samesite='none' requires cookie_secure=true.") + if self.smtp_use_tls and self.smtp_start_tls: + raise ValueError( + "smtp_use_tls and smtp_start_tls are mutually exclusive. Implicit TLS " + "wraps the connection from the first byte, STARTTLS upgrades a plain one." + ) + + if self.password_min_length > self.password_max_length: + raise ValueError("password_min_length cannot exceed password_max_length.") + if self.is_production: secret = ( self.jwt_secret_key.get_secret_value() if self.jwt_secret_key else "" @@ -125,6 +215,14 @@ def _reject_unsafe_combinations(self) -> Settings: f"JWT_SECRET_KEY must be at least {MIN_SECRET_LENGTH} characters in production." ) + otp_secret = ( + self.otp_hash_secret.get_secret_value() if self.otp_hash_secret else "" + ) + if len(otp_secret) < MIN_SECRET_LENGTH: + raise ValueError( + f"OTP_HASH_SECRET must be at least {MIN_SECRET_LENGTH} characters in production." + ) + if not self.cookie_secure: raise ValueError("cookie_secure cannot be disabled in production.") @@ -136,6 +234,28 @@ def _reject_unsafe_combinations(self) -> Settings: f"cors_origins must use https in production: {insecure}" ) + if self.email_delivery is not EmailDelivery.SMTP: + raise ValueError( + "EMAIL_DELIVERY must be 'smtp' in production. 'console' only writes " + "verification codes to the log, so nobody can finish signing up." + ) + + if self.smtp_host in LOCAL_SMTP_HOSTS: + raise ValueError( + f"smtp_host cannot be a local mail catcher in production: {self.smtp_host}" + ) + + if not (self.smtp_start_tls or self.smtp_use_tls): + raise ValueError( + "SMTP in production requires TLS. Set SMTP_START_TLS=true (port 587) " + "or SMTP_USE_TLS=true (port 465)." + ) + + if self.smtp_credentials is None: + raise ValueError( + "SMTP_USERNAME and SMTP_PASSWORD are both required in production." + ) + return self diff --git a/packages/backend/src/milo_backend/dao/__init__.py b/packages/backend/src/milo_backend/dao/__init__.py index e69de29..5f0edfa 100644 --- a/packages/backend/src/milo_backend/dao/__init__.py +++ b/packages/backend/src/milo_backend/dao/__init__.py @@ -0,0 +1,8 @@ +from milo_backend.dao._base import BaseDAO +from milo_backend.dao.auth import EmailVerificationDAO, UserDAO + +__all__ = [ + "BaseDAO", + "EmailVerificationDAO", + "UserDAO", +] diff --git a/packages/backend/src/milo_backend/dao/_base.py b/packages/backend/src/milo_backend/dao/_base.py index 7436b42..f0c1a47 100644 --- a/packages/backend/src/milo_backend/dao/_base.py +++ b/packages/backend/src/milo_backend/dao/_base.py @@ -1,18 +1,16 @@ -from milo_backend.core._database import get_database +from __future__ import annotations + +from typing import Any + +from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase -from pymongo.asynchronous.database import AsyncDatabase class BaseDAO: - - - collection_name :str - - def __init__(self,db:AsyncDatabase)->None: + collection_name: str + + def __init__(self, db: AsyncIOMotorDatabase[dict[str, Any]]) -> None: self.db = db - @property - def collection(self): + def collection(self) -> AsyncIOMotorCollection[dict[str, Any]]: return self.db[self.collection_name] - - \ No newline at end of file diff --git a/packages/backend/src/milo_backend/dao/auth.py b/packages/backend/src/milo_backend/dao/auth.py index e69de29..7146a90 100644 --- a/packages/backend/src/milo_backend/dao/auth.py +++ b/packages/backend/src/milo_backend/dao/auth.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from bson import ObjectId +from bson.errors import InvalidId +from pymongo import ASCENDING, ReturnDocument +from pymongo.errors import DuplicateKeyError, OperationFailure + +from milo_backend.dao._base import BaseDAO +from milo_backend.model.email_verification import EmailVerification, VerificationStatus +from milo_backend.model.user import User + +INDEX_OPTIONS_CONFLICT = 85 + +TTL_INDEX = "ttl_verification_last_sent_at" + + +def _with_id(document: dict[str, Any]) -> dict[str, Any]: + return {**document, "id": str(document["_id"])} + + +def _object_id(value: str) -> ObjectId | None: + try: + return ObjectId(value) + except (InvalidId, TypeError): + return None + + +class UserDAO(BaseDAO): + collection_name = "users" + + async def create_indexes(self) -> None: + await self.collection.create_index( + [("email", ASCENDING)], unique=True, name="uniq_user_email" + ) + + async def create( + self, + *, + name: str, + email: str, + password_hash: str, + now: datetime, + ) -> User | None: + document: dict[str, Any] = { + "name": name, + "email": email, + "password_hash": password_hash, + "email_verified": False, + "created_at": now, + "updated_at": now, + "last_login_at": None, + } + + try: + result = await self.collection.insert_one(document) + except DuplicateKeyError: + return None + + return User.model_validate({**document, "id": str(result.inserted_id)}) + + async def get_by_email(self, email: str) -> User | None: + document = await self.collection.find_one({"email": email}) + return User.model_validate(_with_id(document)) if document else None + + async def get_by_id(self, user_id: str) -> User | None: + identifier = _object_id(user_id) + if identifier is None: + return None + + document = await self.collection.find_one({"_id": identifier}) + return User.model_validate(_with_id(document)) if document else None + + async def mark_email_verified(self, user_id: str, *, now: datetime) -> None: + identifier = _object_id(user_id) + if identifier is None: + return + + await self.collection.update_one( + {"_id": identifier}, + {"$set": {"email_verified": True, "updated_at": now}}, + ) + + async def record_login(self, user_id: str, *, now: datetime) -> None: + identifier = _object_id(user_id) + if identifier is None: + return + + await self.collection.update_one( + {"_id": identifier}, + {"$set": {"last_login_at": now, "updated_at": now}}, + ) + + +class EmailVerificationDAO(BaseDAO): + collection_name = "email_verifications" + + async def create_indexes(self, *, ttl_seconds: int) -> None: + await self.collection.create_index( + [("user_id", ASCENDING)], unique=True, name="uniq_verification_user" + ) + + try: + await self._create_ttl_index(ttl_seconds) + except OperationFailure as error: + if error.code != INDEX_OPTIONS_CONFLICT: + raise + await self.collection.drop_index(TTL_INDEX) + await self._create_ttl_index(ttl_seconds) + + async def _create_ttl_index(self, ttl_seconds: int) -> None: + await self.collection.create_index( + [("last_sent_at", ASCENDING)], + expireAfterSeconds=ttl_seconds, + name=TTL_INDEX, + ) + + async def upsert_pending( + self, + *, + user_id: str, + code_hash: str, + now: datetime, + ) -> EmailVerification: + identifier = _require_object_id(user_id) + + document = await self.collection.find_one_and_update( + {"user_id": identifier}, + { + "$set": { + "code_hash": code_hash, + "status": VerificationStatus.PENDING.value, + "attempts": 0, + "last_sent_at": now, + "verified_at": None, + }, + "$setOnInsert": {"created_at": now}, + }, + upsert=True, + return_document=ReturnDocument.AFTER, + ) + return _as_verification(document) + + async def get_by_user_id(self, user_id: str) -> EmailVerification | None: + identifier = _object_id(user_id) + if identifier is None: + return None + + document = await self.collection.find_one({"user_id": identifier}) + return _as_verification(document) if document else None + + async def increment_attempts(self, user_id: str) -> int: + identifier = _object_id(user_id) + if identifier is None: + return 0 + + document = await self.collection.find_one_and_update( + {"user_id": identifier}, + {"$inc": {"attempts": 1}}, + return_document=ReturnDocument.AFTER, + projection={"attempts": 1}, + ) + return int(document["attempts"]) if document else 0 + + async def mark_verified(self, user_id: str, *, now: datetime) -> None: + identifier = _object_id(user_id) + if identifier is None: + return + + await self.collection.update_one( + {"user_id": identifier}, + { + "$set": { + "status": VerificationStatus.VERIFIED.value, + "verified_at": now, + } + }, + ) + + +def _require_object_id(value: str) -> ObjectId: + identifier = _object_id(value) + if identifier is None: + raise ValueError(f"Not a Mongo identifier: {value!r}") + return identifier + + +def _as_verification(document: dict[str, Any]) -> EmailVerification: + return EmailVerification.model_validate( + {**_with_id(document), "user_id": str(document["user_id"])} + ) diff --git a/packages/backend/src/milo_backend/dependencies/__init__.py b/packages/backend/src/milo_backend/dependencies/__init__.py new file mode 100644 index 0000000..d193e18 --- /dev/null +++ b/packages/backend/src/milo_backend/dependencies/__init__.py @@ -0,0 +1,17 @@ +from milo_backend.dependencies.service import ( + AuthServiceDep, + CurrentUser, + SettingsDep, + ensure_indexes, + get_auth_service, + get_current_user, +) + +__all__ = [ + "AuthServiceDep", + "CurrentUser", + "SettingsDep", + "ensure_indexes", + "get_auth_service", + "get_current_user", +] diff --git a/packages/backend/src/milo_backend/dependencies/service.py b/packages/backend/src/milo_backend/dependencies/service.py index 139597f..4380f66 100644 --- a/packages/backend/src/milo_backend/dependencies/service.py +++ b/packages/backend/src/milo_backend/dependencies/service.py @@ -1,2 +1,46 @@ +from __future__ import annotations +from typing import Annotated +from fastapi import Cookie, Depends + +from milo_backend.core import ACCESS_TOKEN_COOKIE, Settings, get_database, get_settings +from milo_backend.dao.auth import EmailVerificationDAO, UserDAO +from milo_backend.exception.auth import InvalidToken +from milo_backend.model.user import User +from milo_backend.service.auth import AuthService + +SettingsDep = Annotated[Settings, Depends(get_settings)] + + +def get_auth_service(settings: SettingsDep) -> AuthService: + db = get_database() + return AuthService( + users=UserDAO(db), + verifications=EmailVerificationDAO(db), + settings=settings, + ) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] + + +async def get_current_user( + service: AuthServiceDep, + access_token: Annotated[str | None, Cookie(alias=ACCESS_TOKEN_COOKIE)] = None, +) -> User: + if not access_token: + raise InvalidToken() + + return await service.resolve_access_token(access_token=access_token) + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +async def ensure_indexes(*, settings: Settings) -> None: + db = get_database() + await UserDAO(db).create_indexes() + await EmailVerificationDAO(db).create_indexes( + ttl_seconds=settings.otp_record_ttl_seconds + ) diff --git a/packages/backend/src/milo_backend/exception/__init__.py b/packages/backend/src/milo_backend/exception/__init__.py index 46841bd..5518e88 100644 --- a/packages/backend/src/milo_backend/exception/__init__.py +++ b/packages/backend/src/milo_backend/exception/__init__.py @@ -1,10 +1,33 @@ -from milo_backend.exception.auth import UserAlreadyExists, UserNotFound +from milo_backend.exception.auth import ( + EmailAlreadyVerified, + EmailNotVerified, + InvalidCredentials, + InvalidToken, + ResendTooSoon, + TooManyVerificationAttempts, + UserAlreadyExists, + UserNotFound, + VerificationCodeExpired, + VerificationCodeInvalid, + VerificationNotFound, + WeakPassword, +) from milo_backend.exception.base import AppException from milo_backend.exception.handlers import register_exception_handlers __all__ = [ "AppException", + "EmailAlreadyVerified", + "EmailNotVerified", + "InvalidCredentials", + "InvalidToken", + "ResendTooSoon", + "TooManyVerificationAttempts", "UserAlreadyExists", "UserNotFound", + "VerificationCodeExpired", + "VerificationCodeInvalid", + "VerificationNotFound", + "WeakPassword", "register_exception_handlers", ] diff --git a/packages/backend/src/milo_backend/exception/auth.py b/packages/backend/src/milo_backend/exception/auth.py index 506c6e9..85ed61a 100644 --- a/packages/backend/src/milo_backend/exception/auth.py +++ b/packages/backend/src/milo_backend/exception/auth.py @@ -15,3 +15,63 @@ class UserNotFound(AppException): status_code = HTTPStatus.NOT_FOUND code = "user_not_found" msg = "No account matches those details." + + +class InvalidCredentials(AppException): + status_code = HTTPStatus.UNAUTHORIZED + code = "invalid_credentials" + msg = "That email and password do not match." + + +class WeakPassword(AppException): + status_code = HTTPStatus.UNPROCESSABLE_ENTITY + code = "weak_password" + msg = "That password is too short." + + +class EmailNotVerified(AppException): + status_code = HTTPStatus.FORBIDDEN + code = "email_not_verified" + msg = "Verify your email address before logging in." + + +class EmailAlreadyVerified(AppException): + status_code = HTTPStatus.CONFLICT + code = "email_already_verified" + msg = "That email address is already verified." + + +class VerificationNotFound(AppException): + status_code = HTTPStatus.NOT_FOUND + code = "verification_not_found" + msg = "Request a new verification code." + + +class VerificationCodeInvalid(AppException): + status_code = HTTPStatus.BAD_REQUEST + code = "verification_code_invalid" + msg = "That code is not correct." + + +class VerificationCodeExpired(AppException): + status_code = HTTPStatus.BAD_REQUEST + code = "verification_code_expired" + msg = "That code has expired. Request a new one." + + +class TooManyVerificationAttempts(AppException): + status_code = HTTPStatus.TOO_MANY_REQUESTS + code = "too_many_verification_attempts" + msg = "Too many incorrect codes. Request a new one." + + +class ResendTooSoon(AppException): + status_code = HTTPStatus.TOO_MANY_REQUESTS + code = "resend_too_soon" + msg = "A code was just sent. Wait a moment before asking for another." + + +class InvalidToken(AppException): + status_code = HTTPStatus.UNAUTHORIZED + code = "invalid_token" + msg = "Your session has expired. Log in again." diff --git a/packages/backend/src/milo_backend/model/__init__.py b/packages/backend/src/milo_backend/model/__init__.py index e69de29..6f1b9e8 100644 --- a/packages/backend/src/milo_backend/model/__init__.py +++ b/packages/backend/src/milo_backend/model/__init__.py @@ -0,0 +1,8 @@ +from milo_backend.model.email_verification import EmailVerification, VerificationStatus +from milo_backend.model.user import User + +__all__ = [ + "EmailVerification", + "User", + "VerificationStatus", +] diff --git a/packages/backend/src/milo_backend/model/email_verification.py b/packages/backend/src/milo_backend/model/email_verification.py new file mode 100644 index 0000000..48c7381 --- /dev/null +++ b/packages/backend/src/milo_backend/model/email_verification.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel + + +class VerificationStatus(StrEnum): + PENDING = "pending" + VERIFIED = "verified" + + +class EmailVerification(BaseModel): + id: str + user_id: str + code_hash: str + status: VerificationStatus = VerificationStatus.PENDING + attempts: int = 0 + created_at: datetime + last_sent_at: datetime + verified_at: datetime | None = None diff --git a/packages/backend/src/milo_backend/model/user.py b/packages/backend/src/milo_backend/model/user.py new file mode 100644 index 0000000..825f368 --- /dev/null +++ b/packages/backend/src/milo_backend/model/user.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, EmailStr + + +class User(BaseModel): + id: str + name: str + email: EmailStr + password_hash: str + email_verified: bool = False + created_at: datetime + updated_at: datetime + last_login_at: datetime | None = None diff --git a/packages/backend/src/milo_backend/routes/__init__.py b/packages/backend/src/milo_backend/routes/__init__.py index e69de29..a4d5653 100644 --- a/packages/backend/src/milo_backend/routes/__init__.py +++ b/packages/backend/src/milo_backend/routes/__init__.py @@ -0,0 +1,5 @@ +from milo_backend.routes.auth import router as auth_router + +__all__ = [ + "auth_router", +] diff --git a/packages/backend/src/milo_backend/routes/auth.py b/packages/backend/src/milo_backend/routes/auth.py index e69de29..7950154 100644 --- a/packages/backend/src/milo_backend/routes/auth.py +++ b/packages/backend/src/milo_backend/routes/auth.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Cookie, Request, Response, status + +from milo_backend.core import ( + REFRESH_TOKEN_COOKIE, + clear_auth_cookies, + get_settings, + limiter, + set_auth_cookies, +) +from milo_backend.dependencies import AuthServiceDep, CurrentUser, SettingsDep +from milo_backend.exception.auth import InvalidToken +from milo_backend.model.user import User +from milo_backend.schemas.auth import ( + AuthResponse, + AuthUser, + LoginRequest, + RegisterRequest, + StartVerificationRequest, + VerificationPendingResponse, + VerificationState, + VerificationStatusRequest, + VerificationStatusResponse, + VerifyOtpRequest, +) +from milo_backend.service.auth import TokenPair, VerificationChallenge + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post( + "/register", + response_model=VerificationPendingResponse, + status_code=status.HTTP_201_CREATED, +) +@limiter.limit(lambda: get_settings().rate_limit_register) +async def register( + request: Request, + response: Response, + payload: RegisterRequest, + service: AuthServiceDep, +) -> VerificationPendingResponse: + challenge = await service.register( + name=payload.name, + email=payload.email, + password=payload.password, + ) + return _pending(challenge) + + +@router.post( + "/verify-email/start", + response_model=VerificationPendingResponse, + status_code=status.HTTP_202_ACCEPTED, +) +@limiter.limit(lambda: get_settings().rate_limit_resend_otp) +async def start_email_verification( + request: Request, + response: Response, + payload: StartVerificationRequest, + service: AuthServiceDep, +) -> VerificationPendingResponse: + challenge = await service.start_verification(email=payload.email) + return _pending(challenge) + + +@router.post("/verify-email/status", response_model=VerificationStatusResponse) +@limiter.limit(lambda: get_settings().rate_limit_verification_status) +async def verification_status( + request: Request, + response: Response, + payload: VerificationStatusRequest, + service: AuthServiceDep, +) -> VerificationStatusResponse: + snapshot = await service.verification_status(email=payload.email) + return VerificationStatusResponse( + email=snapshot.email, + status=VerificationState(snapshot.state.value), + expires_in_seconds=snapshot.expires_in_seconds, + resend_after_seconds=snapshot.resend_after_seconds, + attempts_remaining=snapshot.attempts_remaining, + ) + + +@router.post("/verify-otp", response_model=AuthResponse) +@limiter.limit(lambda: get_settings().rate_limit_verify_otp) +async def verify_otp( + request: Request, + response: Response, + payload: VerifyOtpRequest, + service: AuthServiceDep, + settings: SettingsDep, +) -> AuthResponse: + user, tokens = await service.verify_otp(email=payload.email, code=payload.code) + _authenticate(response, tokens=tokens, settings=settings) + return AuthResponse(user=_public(user)) + + +@router.post("/login", response_model=AuthResponse) +@limiter.limit(lambda: get_settings().rate_limit_login) +async def login( + request: Request, + response: Response, + payload: LoginRequest, + service: AuthServiceDep, + settings: SettingsDep, +) -> AuthResponse: + user, tokens = await service.login(email=payload.email, password=payload.password) + _authenticate(response, tokens=tokens, settings=settings) + return AuthResponse(user=_public(user)) + + +@router.post("/refresh", response_model=AuthResponse) +async def refresh( + response: Response, + service: AuthServiceDep, + settings: SettingsDep, + refresh_token: Annotated[str | None, Cookie(alias=REFRESH_TOKEN_COOKIE)] = None, +) -> AuthResponse: + if not refresh_token: + raise InvalidToken() + + user, tokens = await service.refresh(refresh_token=refresh_token) + _authenticate(response, tokens=tokens, settings=settings) + return AuthResponse(user=_public(user)) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response, settings: SettingsDep) -> None: + clear_auth_cookies(response, settings=settings) + + +@router.get("/me", response_model=AuthResponse) +async def me(user: CurrentUser) -> AuthResponse: + return AuthResponse(user=_public(user)) + + +def _authenticate( + response: Response, *, tokens: TokenPair, settings: SettingsDep +) -> None: + set_auth_cookies( + response, + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + settings=settings, + ) + + +def _pending(challenge: VerificationChallenge) -> VerificationPendingResponse: + now = datetime.now(UTC) + return VerificationPendingResponse( + email=challenge.email, + expires_in_seconds=challenge.expires_in_seconds(now), + resend_after_seconds=challenge.resend_after_seconds(now), + ) + + +def _public(user: User) -> AuthUser: + return AuthUser( + id=user.id, + name=user.name, + email=user.email, + email_verified=user.email_verified, + ) diff --git a/packages/backend/src/milo_backend/schemas/__init__.py b/packages/backend/src/milo_backend/schemas/__init__.py index e69de29..b012149 100644 --- a/packages/backend/src/milo_backend/schemas/__init__.py +++ b/packages/backend/src/milo_backend/schemas/__init__.py @@ -0,0 +1,25 @@ +from milo_backend.schemas.auth import ( + AuthResponse, + AuthUser, + LoginRequest, + RegisterRequest, + StartVerificationRequest, + VerificationPendingResponse, + VerificationState, + VerificationStatusRequest, + VerificationStatusResponse, + VerifyOtpRequest, +) + +__all__ = [ + "AuthResponse", + "AuthUser", + "LoginRequest", + "RegisterRequest", + "StartVerificationRequest", + "VerificationPendingResponse", + "VerificationState", + "VerificationStatusRequest", + "VerificationStatusResponse", + "VerifyOtpRequest", +] diff --git a/packages/backend/src/milo_backend/schemas/auth.py b/packages/backend/src/milo_backend/schemas/auth.py index 3d52d8e..2089537 100644 --- a/packages/backend/src/milo_backend/schemas/auth.py +++ b/packages/backend/src/milo_backend/schemas/auth.py @@ -1,3 +1,107 @@ -from pydantic import BaseModel +from __future__ import annotations +import re +from enum import StrEnum +from typing import Annotated +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + EmailStr, + Field, + StringConstraints, +) + +CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") + +CODE_SEPARATORS = re.compile(r"[\s\-]") + + +def _clean_text(value: object) -> object: + if not isinstance(value, str): + return value + return " ".join(CONTROL_CHARACTERS.sub("", value).split()) + + +def _clean_code(value: object) -> object: + if not isinstance(value, str): + return value + return CODE_SEPARATORS.sub("", value) + + +def _normalise_email(value: str) -> str: + return value.strip().lower() + + +Name = Annotated[ + str, + BeforeValidator(_clean_text), + StringConstraints(min_length=1, max_length=80), +] + +NormalisedEmail = Annotated[EmailStr, AfterValidator(_normalise_email)] + +OtpCode = Annotated[ + str, + BeforeValidator(_clean_code), + StringConstraints(pattern=r"^\d{4,10}$"), +] + +Password = Annotated[str, Field(min_length=1, max_length=1_024)] + + +class VerificationState(StrEnum): + NONE = "none" + PENDING = "pending" + VERIFIED = "verified" + + +class RegisterRequest(BaseModel): + name: Name + email: NormalisedEmail + password: Password + + +class LoginRequest(BaseModel): + email: NormalisedEmail + password: Password + + +class StartVerificationRequest(BaseModel): + email: NormalisedEmail + + +class VerifyOtpRequest(BaseModel): + email: NormalisedEmail + code: OtpCode + + +class VerificationStatusRequest(BaseModel): + email: NormalisedEmail + + +class AuthUser(BaseModel): + id: str + name: str + email: EmailStr + email_verified: bool + + +class AuthResponse(BaseModel): + user: AuthUser + + +class VerificationStatusResponse(BaseModel): + email: EmailStr + status: VerificationState + expires_in_seconds: int + resend_after_seconds: int + attempts_remaining: int + + +class VerificationPendingResponse(BaseModel): + email: EmailStr + expires_in_seconds: int + resend_after_seconds: int + msg: str = "We sent you a verification code." diff --git a/packages/backend/src/milo_backend/service/__init__.py b/packages/backend/src/milo_backend/service/__init__.py index e69de29..935fdc1 100644 --- a/packages/backend/src/milo_backend/service/__init__.py +++ b/packages/backend/src/milo_backend/service/__init__.py @@ -0,0 +1,15 @@ +from milo_backend.service.auth import ( + AuthService, + TokenPair, + VerificationChallenge, + VerificationSnapshot, + VerificationState, +) + +__all__ = [ + "AuthService", + "TokenPair", + "VerificationChallenge", + "VerificationSnapshot", + "VerificationState", +] diff --git a/packages/backend/src/milo_backend/service/auth.py b/packages/backend/src/milo_backend/service/auth.py new file mode 100644 index 0000000..ab3ce1b --- /dev/null +++ b/packages/backend/src/milo_backend/service/auth.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import html +import logging +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum + +import jwt + +from milo_backend.core import ( + Settings, + create_token, + decode_token, + generate_otp, + hash_otp, + hash_password, + send_email, + verify_otp, + verify_password, +) +from milo_backend.dao.auth import EmailVerificationDAO, UserDAO +from milo_backend.exception.auth import ( + EmailAlreadyVerified, + EmailNotVerified, + InvalidCredentials, + InvalidToken, + ResendTooSoon, + TooManyVerificationAttempts, + UserAlreadyExists, + UserNotFound, + VerificationCodeExpired, + VerificationCodeInvalid, + VerificationNotFound, + WeakPassword, +) +from milo_backend.model.email_verification import EmailVerification, VerificationStatus +from milo_backend.model.user import User + +logger = logging.getLogger("milo.auth") + + +@dataclass(frozen=True) +class TokenPair: + access_token: str + refresh_token: str + + +@dataclass(frozen=True) +class VerificationChallenge: + email: str + expires_at: datetime + resend_after: datetime + + def expires_in_seconds(self, now: datetime) -> int: + return max(0, int((self.expires_at - now).total_seconds())) + + def resend_after_seconds(self, now: datetime) -> int: + return max(0, int((self.resend_after - now).total_seconds())) + + +class VerificationState(StrEnum): + NONE = "none" + PENDING = "pending" + VERIFIED = "verified" + + +@dataclass(frozen=True) +class VerificationSnapshot: + email: str + state: VerificationState + expires_in_seconds: int = 0 + resend_after_seconds: int = 0 + attempts_remaining: int = 0 + + +class AuthService: + def __init__( + self, + *, + users: UserDAO, + verifications: EmailVerificationDAO, + settings: Settings, + ) -> None: + self.users = users + self.verifications = verifications + self.settings = settings + + async def register( + self, *, name: str, email: str, password: str + ) -> VerificationChallenge: + address = _normalise(email) + self._check_password(password) + + existing = await self.users.get_by_email(address) + if existing is not None and existing.email_verified: + raise UserAlreadyExists() + + now = datetime.now(UTC) + + if existing is not None: + user = existing + else: + created = await self.users.create( + name=name.strip(), + email=address, + password_hash=hash_password(password), + now=now, + ) + if created is None: + raise UserAlreadyExists() + user = created + + return await self._issue_challenge(user, now=now) + + async def start_verification(self, *, email: str) -> VerificationChallenge: + address = _normalise(email) + + user = await self.users.get_by_email(address) + if user is None: + raise UserNotFound() + if user.email_verified: + raise EmailAlreadyVerified() + + return await self._issue_challenge(user, now=datetime.now(UTC)) + + async def verify_otp(self, *, email: str, code: str) -> tuple[User, TokenPair]: + address = _normalise(email) + now = datetime.now(UTC) + + user = await self.users.get_by_email(address) + if user is None: + raise VerificationNotFound() + if user.email_verified: + raise EmailAlreadyVerified() + + record = await self.verifications.get_by_user_id(user.id) + if record is None: + raise VerificationNotFound() + if record.status is VerificationStatus.VERIFIED: + raise EmailAlreadyVerified() + if _expires_at(record, settings=self.settings) <= now: + raise VerificationCodeExpired() + if record.attempts >= self.settings.otp_max_attempts: + raise TooManyVerificationAttempts() + + if not verify_otp(code, record.code_hash, settings=self.settings): + attempts = await self.verifications.increment_attempts(user.id) + remaining = max(0, self.settings.otp_max_attempts - attempts) + if remaining == 0: + raise TooManyVerificationAttempts() + raise VerificationCodeInvalid(details={"attempts_remaining": remaining}) + + await self.users.mark_email_verified(user.id, now=now) + await self.verifications.mark_verified(user.id, now=now) + await self.users.record_login(user.id, now=now) + + verified = user.model_copy( + update={"email_verified": True, "last_login_at": now} + ) + logger.info("Email verified for user %s", user.id) + + return verified, self._issue_tokens(verified) + + async def login(self, *, email: str, password: str) -> tuple[User, TokenPair]: + address = _normalise(email) + + user = await self.users.get_by_email(address) + if user is None: + raise InvalidCredentials() + if not verify_password(password, user.password_hash): + raise InvalidCredentials() + if not user.email_verified: + raise EmailNotVerified(details={"email": user.email}) + + now = datetime.now(UTC) + await self.users.record_login(user.id, now=now) + + return user, self._issue_tokens(user) + + async def refresh(self, *, refresh_token: str) -> tuple[User, TokenPair]: + payload = self._decode(refresh_token, expected_type="refresh") + + user = await self.users.get_by_id(str(payload.get("sub", ""))) + if user is None: + raise InvalidToken() + if not user.email_verified: + raise EmailNotVerified(details={"email": user.email}) + + return user, self._issue_tokens(user) + + async def resolve_access_token(self, *, access_token: str) -> User: + payload = self._decode(access_token, expected_type="access") + + user = await self.users.get_by_id(str(payload.get("sub", ""))) + if user is None: + raise InvalidToken() + + return user + + async def verification_status(self, *, email: str) -> VerificationSnapshot: + address = _normalise(email) + now = datetime.now(UTC) + + user = await self.users.get_by_email(address) + if user is None: + return VerificationSnapshot(email=address, state=VerificationState.NONE) + if user.email_verified: + return VerificationSnapshot(email=address, state=VerificationState.VERIFIED) + + record = await self.verifications.get_by_user_id(user.id) + if record is None: + return VerificationSnapshot(email=address, state=VerificationState.NONE) + + expires_at = _expires_at(record, settings=self.settings) + if expires_at <= now: + return VerificationSnapshot(email=address, state=VerificationState.NONE) + + return VerificationSnapshot( + email=address, + state=VerificationState.PENDING, + expires_in_seconds=_seconds_between(now, expires_at), + resend_after_seconds=_seconds_between( + now, _last_sent_at(record) + self.settings.otp_resend_cooldown + ), + attempts_remaining=max(0, self.settings.otp_max_attempts - record.attempts), + ) + + async def _issue_challenge( + self, user: User, *, now: datetime + ) -> VerificationChallenge: + existing = await self.verifications.get_by_user_id(user.id) + if existing is not None: + ready_at = _last_sent_at(existing) + self.settings.otp_resend_cooldown + if now < ready_at: + raise ResendTooSoon( + details={ + "retry_after_seconds": int((ready_at - now).total_seconds()) + } + ) + + code = generate_otp(settings=self.settings) + expires_at = now + self.settings.otp_expiry + + await self.verifications.upsert_pending( + user_id=user.id, + code_hash=hash_otp(code, settings=self.settings), + now=now, + ) + + await send_email( + to=user.email, + subject=f"{code} is your Milo verification code", + text_body=_verification_text( + name=user.name, code=code, settings=self.settings + ), + html_body=_verification_html( + name=user.name, code=code, settings=self.settings + ), + settings=self.settings, + ) + + logger.info("Verification code issued for user %s", user.id) + + return VerificationChallenge( + email=user.email, + expires_at=expires_at, + resend_after=now + self.settings.otp_resend_cooldown, + ) + + def _issue_tokens(self, user: User) -> TokenPair: + return TokenPair( + access_token=create_token( + subject=user.id, + token_type="access", + expires_in=self.settings.access_token_expiry, + settings=self.settings, + ), + refresh_token=create_token( + subject=user.id, + token_type="refresh", + expires_in=self.settings.refresh_token_expiry, + settings=self.settings, + ), + ) + + def _decode(self, token: str, *, expected_type: str) -> dict[str, object]: + try: + return decode_token( + token, + expected_type="refresh" if expected_type == "refresh" else "access", + settings=self.settings, + ) + except jwt.PyJWTError as error: + raise InvalidToken() from error + + def _check_password(self, password: str) -> None: + if len(password) < self.settings.password_min_length: + raise WeakPassword( + msg=f"Use at least {self.settings.password_min_length} characters.", + details={"min_length": self.settings.password_min_length}, + ) + if len(password) > self.settings.password_max_length: + raise WeakPassword( + msg=f"Use at most {self.settings.password_max_length} characters.", + details={"max_length": self.settings.password_max_length}, + ) + + +def _normalise(email: str) -> str: + return email.strip().lower() + + +def _seconds_between(now: datetime, later: datetime) -> int: + return max(0, int((later - now).total_seconds())) + + +def _expires_at(record: EmailVerification, *, settings: Settings) -> datetime: + return _last_sent_at(record) + settings.otp_expiry + + +def _last_sent_at(record: EmailVerification) -> datetime: + return _as_utc(record.last_sent_at) + + +def _as_utc(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _verification_text(*, name: str, code: str, settings: Settings) -> str: + minutes = settings.otp_expire_minutes + return ( + f"Hi {name},\n\n" + f"Your Milo verification code is {code}.\n" + f"It expires in {minutes} minutes.\n\n" + "If you did not create a Milo account, ignore this email.\n" + ) + + +def _verification_html(*, name: str, code: str, settings: Settings) -> str: + minutes = settings.otp_expire_minutes + return ( + '' + f"

Hi {html.escape(name)},

" + "

Your Milo verification code is

" + f'

{code}

' + f"

It expires in {minutes} minutes.

" + '

If you did not create a Milo account, ' + "ignore this email.

" + "" + ) diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example new file mode 100644 index 0000000..ec35e96 --- /dev/null +++ b/packages/frontend/.env.example @@ -0,0 +1,16 @@ +# Base URL of the FastAPI backend, read by lib/axios.config.ts. +# +# NEXT_PUBLIC_ is required: this value is read in the browser, where only +# NEXT_PUBLIC_* vars exist. It is a public URL, not a secret. +# +# Whatever origin this app is served from must appear in the backend's +# CORS_ORIGINS, or every authenticated request fails a preflight that the +# browser reports to JavaScript as an indistinguishable network error. +NEXT_PUBLIC_API_URL=http://127.0.0.1:8000 + +# Optional server-only override, used when server components reach the backend +# over an internal address the browser cannot resolve. Ignored in the browser. +API_URL= + +# Request timeout in milliseconds. Defaults to 15000 when unset or unparseable. +NEXT_PUBLIC_API_TIMEOUT_MS=15000 diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore index 5ef6a52..4fa696c 100644 --- a/packages/frontend/.gitignore +++ b/packages/frontend/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# ...except the documented template, as packages/landing and packages/backend do. +!.env.example # vercel .vercel diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 97cc64b..2df53a5 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -13,6 +13,7 @@ "@milo/styles": "*", "@milo/ui": "*", "@react-three/fiber": "^9.7.0", + "axios": "^1.20.0", "clsx": "^2.1.1", "lucide-react": "^0.545.0", "next": "16.3.0", diff --git a/packages/frontend/src/app/(marketing)/layout.tsx b/packages/frontend/src/app/(marketing)/layout.tsx index a1a1e90..e9a5200 100644 --- a/packages/frontend/src/app/(marketing)/layout.tsx +++ b/packages/frontend/src/app/(marketing)/layout.tsx @@ -1,13 +1,6 @@ import { MarketingFooter } from "@/components/marketing/marketing-footer"; import { MarketingHeader } from "@/components/marketing/marketing-header"; -/** - * Shell for every public, unauthenticated page. - * - * A route group, so these pages sit at /features and /path rather than - * /marketing/features. The authenticated app will get its own group and its own - * chrome without inheriting any of this. - */ export default function MarketingLayout({ children }: { children: React.ReactNode }) { return (
diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css index f9077a1..1ae5347 100644 --- a/packages/frontend/src/app/globals.css +++ b/packages/frontend/src/app/globals.css @@ -1,11 +1,3 @@ -/* - * Entry stylesheet for the product app. - * - * Tokens and base styles live in packages/styles/globals.css so every Milo app - * shares one palette. Only the source globs are app-specific: Tailwind's - * automatic content detection resolves relative to the imported file, which - * would point into node_modules rather than at this app. - */ @import "@milo/styles/globals.css"; @source "../../src"; diff --git a/packages/frontend/src/components/marketing/candidate-illustration.tsx b/packages/frontend/src/components/marketing/candidate-illustration.tsx index 0d57452..9c87e07 100644 --- a/packages/frontend/src/components/marketing/candidate-illustration.tsx +++ b/packages/frontend/src/components/marketing/candidate-illustration.tsx @@ -2,32 +2,15 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; -/** - * Aarav at his laptop, with the current beat played out around him. - * - * The figure itself is a professionally drawn unDraw illustration, recoloured - * to the Milo palette, rather than paths written by hand. Hand-authored bezier - * coordinates do not produce a convincing human, and the earlier attempt at one - * proved it. - * - * What is authored here is the motion: applications leaving the laptop, and the - * single signal that eventually comes back. Those are the parts that carry the - * argument, and they have to be driven by the story state. - */ - export type CandidatePose = "typing" | "waiting" | "slumped" | "signal"; type Props = { pose: CandidatePose; - /** - * Increments on every application sent. Used as a React key so the paper - * animation restarts rather than playing once and stopping. - */ + sendCount: number; className?: string; }; -/** How present the scene feels, per beat. Waiting and slumping dim it. */ const sceneOpacity: Record = { typing: 1, waiting: 0.78, @@ -35,13 +18,6 @@ const sceneOpacity: Record = { signal: 1, }; -/** - * Whole-figure motion, per beat. - * - * The illustration is a flat asset, so its internals cannot be posed. Moving - * the figure as a unit still reads clearly: working, waiting, sinking, then - * lifting when something finally comes back. - */ const figureMotion: Record = { typing: "animate-typing-bob", waiting: "animate-breathe", @@ -59,8 +35,6 @@ const figureTransform: Record = { export function CandidateIllustration({ pose, sendCount, className }: Props) { return (
- {/* Outer element carries the pose, inner one the looping motion, so the - two transforms never fight over the same property. */}
- {/* Motion layer, sized to the same box so coordinates line up. */}