├── tests ├── __init__.py ├── routes │ ├── __init__.py │ ├── test_auth.py │ └── test_user.py ├── requirements.txt ├── data.py ├── util.py └── conftest.py ├── myserver ├── models │ ├── __init__.py │ ├── auth.py │ └── user.py ├── util │ ├── __init__.py │ ├── password.py │ ├── current_user.py │ └── mail.py ├── routes │ ├── __init__.py │ ├── user.py │ ├── auth.py │ ├── mail.py │ └── register.py ├── __init__.py ├── main.py ├── config.py ├── jwt.py └── app.py ├── .env ├── gen_salt.py ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample server test suite.""" 2 | -------------------------------------------------------------------------------- /myserver/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample server models.""" 2 | -------------------------------------------------------------------------------- /myserver/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample server utilities.""" 2 | -------------------------------------------------------------------------------- /myserver/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample server route handlers.""" 2 | -------------------------------------------------------------------------------- /tests/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample server route handling tests.""" 2 | -------------------------------------------------------------------------------- /myserver/__init__.py: -------------------------------------------------------------------------------- 1 | """Sample FastAPI server with JWT auth and Beanie ODM.""" 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | asgi-lifespan~=1.0 2 | httpx~=0.18 3 | pytest-asyncio>=0.15.1 -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # FastAPI 2 | SECRET_KEY="Change Me" 3 | SALT="See README" 4 | 5 | # MongoDB Client 6 | MONGO_URI="mongodb://localhost:27017" 7 | 8 | # FastMail 9 | MAIL_CONSOLE=true 10 | MAIL_USERNAME="postmaster@myserver.io" 11 | MAIL_PASSWORD="mail server password" 12 | -------------------------------------------------------------------------------- /myserver/util/password.py: -------------------------------------------------------------------------------- 1 | """Password utility functions.""" 2 | 3 | import bcrypt 4 | 5 | from myserver.config import CONFIG 6 | 7 | 8 | def hash_password(password: str) -> str: 9 | """Return a salted password hash.""" 10 | return bcrypt.hashpw(password.encode(), CONFIG.salt).decode() 11 | -------------------------------------------------------------------------------- /gen_salt.py: -------------------------------------------------------------------------------- 1 | """Generate a new bcrypt password salt and updates to local .env file.""" 2 | 3 | from pathlib import Path 4 | import bcrypt 5 | 6 | path = Path.cwd() / ".env" 7 | env = path.read_text() 8 | target = 'SALT="' 9 | start = env.find(target) + len(target) 10 | prefix, postfix = env[:start], env[start:] 11 | end = postfix.find('"') 12 | output = prefix + str(bcrypt.gensalt())[2:-1] + postfix[end:] 13 | with path.open("w") as out: 14 | out.write(output) 15 | -------------------------------------------------------------------------------- /myserver/main.py: -------------------------------------------------------------------------------- 1 | """Server main runtime.""" 2 | 3 | from myserver.app import app 4 | from myserver.routes.auth import router as AuthRouter 5 | from myserver.routes.mail import router as MailRouter 6 | from myserver.routes.register import router as RegisterRouter 7 | from myserver.routes.user import router as UserRouter 8 | 9 | 10 | app.include_router(AuthRouter) 11 | app.include_router(MailRouter) 12 | app.include_router(RegisterRouter) 13 | app.include_router(UserRouter) 14 | -------------------------------------------------------------------------------- /myserver/models/auth.py: -------------------------------------------------------------------------------- 1 | """Auth response models.""" 2 | 3 | from datetime import timedelta 4 | 5 | from pydantic import BaseModel 6 | 7 | from myserver.jwt import ACCESS_EXPIRES, REFRESH_EXPIRES 8 | 9 | 10 | class AccessToken(BaseModel): 11 | """Access token details.""" 12 | 13 | access_token: str 14 | access_token_expires: timedelta = ACCESS_EXPIRES 15 | 16 | 17 | class RefreshToken(AccessToken): 18 | """Access and refresh token details.""" 19 | 20 | refresh_token: str 21 | refresh_token_expires: timedelta = REFRESH_EXPIRES 22 | -------------------------------------------------------------------------------- /myserver/util/current_user.py: -------------------------------------------------------------------------------- 1 | """Current user dependency.""" 2 | 3 | from fastapi import HTTPException, Security 4 | from fastapi_jwt import JwtAuthorizationCredentials 5 | 6 | from myserver.models.user import User 7 | from myserver.jwt import access_security, user_from_credentials 8 | 9 | 10 | async def current_user( 11 | auth: JwtAuthorizationCredentials = Security(access_security) 12 | ) -> User: 13 | """Return the current authorized user.""" 14 | if not auth: 15 | raise HTTPException(401, "No authorization credentials found") 16 | user = await user_from_credentials(auth) 17 | if user is None: 18 | raise HTTPException(404, "Authorized user could not be found") 19 | return user 20 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | """Test data handlers.""" 2 | 3 | from datetime import datetime, timedelta, UTC 4 | 5 | from myserver.models.user import User 6 | from myserver.util.password import hash_password 7 | 8 | 9 | def make_user(email: str, offset: int | None = 0) -> User: 10 | """Return a minimal, uncommitted User.""" 11 | now = None 12 | if offset is not None: 13 | now = datetime.now(tz=UTC) - timedelta(days=offset) 14 | user = User( 15 | email=email, 16 | password=hash_password(email), 17 | email_confirmed_at=now, 18 | ) 19 | return user 20 | 21 | 22 | async def add_empty_user() -> str: 23 | """Add minimal user to user collection.""" 24 | user = make_user("empty@test.io") 25 | await user.create() 26 | return user.email 27 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | """Common test utilities.""" 2 | 3 | from httpx import AsyncClient 4 | 5 | from myserver.models.auth import RefreshToken 6 | 7 | 8 | def auth_header_token(token: str) -> dict[str, str]: 9 | """Create authorization headers with a token value.""" 10 | return {"Authorization": f"Bearer {token}"} 11 | 12 | 13 | async def auth_payload( 14 | client: AsyncClient, email: str, password: str | None = None 15 | ) -> RefreshToken: 16 | """Return the login auth payload for an email.""" 17 | data = {"email": email, "password": password or email} 18 | resp = await client.post("/auth/login", json=data) 19 | return RefreshToken(**resp.json()) 20 | 21 | 22 | async def auth_headers( 23 | client: AsyncClient, email: str, password: str | None = None 24 | ) -> dict[str, str]: 25 | """Return the authorization headers for an email.""" 26 | auth = await auth_payload(client, email, password) 27 | return auth_header_token(auth.access_token) 28 | -------------------------------------------------------------------------------- /myserver/config.py: -------------------------------------------------------------------------------- 1 | """FastAPI server configuration.""" 2 | 3 | from decouple import config 4 | from pydantic import BaseModel 5 | 6 | 7 | class Settings(BaseModel): 8 | """Server config settings.""" 9 | 10 | root_url: str = config("ROOT_URL", default="http://localhost:8080") 11 | 12 | # Mongo Engine settings 13 | mongo_uri: str = config("MONGO_URI") 14 | 15 | # Security settings 16 | authjwt_secret_key: str = config("SECRET_KEY") 17 | salt: bytes = config("SALT").encode() 18 | 19 | # FastMail SMTP server settings 20 | mail_console: bool = config("MAIL_CONSOLE", default=False, cast=bool) 21 | mail_server: str = config("MAIL_SERVER", default="smtp.myserver.io") 22 | mail_port: int = config("MAIL_PORT", default=587, cast=int) 23 | mail_username: str = config("MAIL_USERNAME", default="") 24 | mail_password: str = config("MAIL_PASSWORD", default="") 25 | mail_sender: str = config("MAIL_SENDER", default="noreply@myserver.io") 26 | 27 | testing: bool = config("TESTING", default=False, cast=bool) 28 | 29 | 30 | CONFIG = Settings() 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Michael duPont 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /myserver/jwt.py: -------------------------------------------------------------------------------- 1 | """FastAPI JWT configuration.""" 2 | 3 | from datetime import timedelta 4 | 5 | from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearer, JwtRefreshBearer 6 | 7 | from myserver.config import CONFIG 8 | from myserver.models.user import User 9 | 10 | ACCESS_EXPIRES = timedelta(minutes=15) 11 | REFRESH_EXPIRES = timedelta(days=30) 12 | 13 | access_security = JwtAccessBearer( 14 | CONFIG.authjwt_secret_key, 15 | access_expires_delta=ACCESS_EXPIRES, 16 | refresh_expires_delta=REFRESH_EXPIRES, 17 | ) 18 | 19 | refresh_security = JwtRefreshBearer( 20 | CONFIG.authjwt_secret_key, 21 | access_expires_delta=ACCESS_EXPIRES, 22 | refresh_expires_delta=REFRESH_EXPIRES, 23 | ) 24 | 25 | 26 | async def user_from_credentials(auth: JwtAuthorizationCredentials) -> User | None: 27 | """Return the user associated with auth credentials.""" 28 | return await User.by_email(auth.subject["username"]) 29 | 30 | 31 | async def user_from_token(token: str) -> User | None: 32 | """Return the user associated with a token value.""" 33 | payload = access_security._decode(token) 34 | return await User.by_email(payload["subject"]["username"]) 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name="fastapi-beanie-jwt" 3 | version="0.1.0" 4 | description = "Sample FastAPI server with JWT auth and Beanie ODM" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | authors = [ 8 | {name = "Michael duPont", email="michael@dupont.dev"} 9 | ] 10 | dependencies = [ 11 | "bcrypt~=4.0", 12 | "beanie~=1.23", 13 | "fastapi~=0.104", 14 | "fastapi-jwt>=0.2", 15 | "fastapi-mail~=1.4", 16 | "python-decouple~=3.8", 17 | "uvicorn~=0.24", 18 | ] 19 | 20 | [project.optional-dependencies] 21 | dev = ["mypy", "ruff"] 22 | test = [ 23 | "asgi-lifespan~=2.1", 24 | "httpx~=0.25", 25 | "pytest-asyncio~=0.21", 26 | ] 27 | 28 | [project.urls] 29 | "Source" = "https://github.com/flyinactor91/fastapi-beanie-jwt" 30 | 31 | [tool.mypy] 32 | check_untyped_defs = true 33 | disallow_any_unimported = false 34 | disallow_untyped_defs = true 35 | explicit_package_bases = true 36 | ignore_missing_imports = true 37 | no_implicit_optional = true 38 | show_error_codes = true 39 | strict_equality = true 40 | warn_redundant_casts = true 41 | warn_return_any = true 42 | warn_unused_ignores = true 43 | 44 | [tool.ruff] 45 | extend-select = [ 46 | "UP", 47 | "D", 48 | ] 49 | ignore = [ 50 | "D105", 51 | "D203", 52 | "D213", 53 | ] -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest fixtures.""" 2 | 3 | from collections.abc import AsyncIterator 4 | 5 | import pytest_asyncio 6 | from asgi_lifespan import LifespanManager 7 | from decouple import config 8 | from fastapi import FastAPI 9 | from httpx import AsyncClient 10 | 11 | from myserver.config import CONFIG 12 | 13 | 14 | # Override config settings before loading the app 15 | CONFIG.testing = True 16 | CONFIG.mongo_uri = config("TEST_MONGO_URI", default="mongodb://localhost:27017") 17 | 18 | from myserver.main import app # noqa: E402 19 | 20 | 21 | async def clear_database(server: FastAPI) -> None: 22 | """Empty the test database.""" 23 | async for collection in await server.db.list_collections(): # type: ignore[attr-defined] 24 | await server.db[collection["name"]].delete_many({}) # type: ignore[attr-defined] 25 | 26 | 27 | @pytest_asyncio.fixture() 28 | async def client() -> AsyncIterator[AsyncClient]: 29 | """Async server client that handles lifespan and teardown.""" 30 | async with LifespanManager(app): 31 | async with AsyncClient(app=app, base_url="http://test") as _client: 32 | try: 33 | yield _client 34 | except Exception as exc: 35 | print(exc) 36 | finally: 37 | await clear_database(app) 38 | -------------------------------------------------------------------------------- /tests/routes/test_auth.py: -------------------------------------------------------------------------------- 1 | """Authentication tests.""" 2 | 3 | import pytest 4 | from httpx import AsyncClient 5 | 6 | from tests.data import add_empty_user 7 | from tests.util import auth_header_token, auth_payload 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_not_authorized(client: AsyncClient) -> None: 12 | """Test user not authorized if required.""" 13 | resp = await client.get("/user") 14 | assert resp.status_code == 401 15 | headers = auth_header_token("eyJ0eXAiOiJKV1QiLCJhbG") 16 | resp = await client.get("/user", headers=headers) 17 | assert resp.status_code == 401 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_refresh(client: AsyncClient) -> None: 22 | """Test refresh token updates access token.""" 23 | email = await add_empty_user() 24 | # Check login 25 | auth = await auth_payload(client, email) 26 | headers = auth_header_token(auth.access_token) 27 | resp = await client.get("/user", headers=headers) 28 | assert resp.status_code == 200 29 | # Token refresh 30 | headers = auth_header_token(auth.refresh_token) 31 | resp = await client.post("/auth/refresh", headers=headers) 32 | assert resp.status_code == 200 33 | # Check second call 34 | headers = auth_header_token(resp.json()["access_token"]) 35 | resp = await client.get("/user", headers=headers) 36 | assert resp.status_code == 200 37 | -------------------------------------------------------------------------------- /myserver/routes/user.py: -------------------------------------------------------------------------------- 1 | """User router.""" 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, Response, Security 4 | from fastapi_jwt import JwtAuthorizationCredentials 5 | 6 | from myserver.models.user import User, UserOut, UserUpdate 7 | from myserver.jwt import access_security 8 | from myserver.util.current_user import current_user 9 | 10 | router = APIRouter(prefix="/user", tags=["User"]) 11 | 12 | 13 | @router.get("", response_model=UserOut) 14 | async def get_user(user: User = Depends(current_user)): # type: ignore[no-untyped-def] 15 | """Return the current user.""" 16 | return user 17 | 18 | 19 | @router.patch("", response_model=UserOut) 20 | async def update_user(update: UserUpdate, user: User = Depends(current_user)): # type: ignore[no-untyped-def] 21 | """Update allowed user fields.""" 22 | fields = update.model_dump(exclude_unset=True) 23 | if new_email := fields.pop("email", None): 24 | if new_email != user.email: 25 | if await User.by_email(new_email) is not None: 26 | raise HTTPException(400, "Email already exists") 27 | user.update_email(new_email) 28 | user = user.model_copy(update=fields) 29 | await user.save() 30 | return user 31 | 32 | 33 | @router.delete("") 34 | async def delete_user( 35 | auth: JwtAuthorizationCredentials = Security(access_security) 36 | ) -> Response: 37 | """Delete current user.""" 38 | await User.find_one(User.email == auth.subject["username"]).delete() 39 | return Response(status_code=204) 40 | -------------------------------------------------------------------------------- /myserver/routes/auth.py: -------------------------------------------------------------------------------- 1 | """Authentication router.""" 2 | 3 | from fastapi import APIRouter, HTTPException, Security 4 | from fastapi_jwt import JwtAuthorizationCredentials 5 | 6 | from myserver.models.auth import AccessToken, RefreshToken 7 | from myserver.models.user import User, UserAuth 8 | from myserver.jwt import access_security, refresh_security 9 | from myserver.util.password import hash_password 10 | 11 | 12 | router = APIRouter(prefix="/auth", tags=["Auth"]) 13 | 14 | 15 | @router.post("/login") 16 | async def login(user_auth: UserAuth) -> RefreshToken: 17 | """Authenticate and returns the user's JWT.""" 18 | user = await User.by_email(user_auth.email) 19 | if user is None or hash_password(user_auth.password) != user.password: 20 | raise HTTPException(status_code=401, detail="Bad email or password") 21 | if user.email_confirmed_at is None: 22 | raise HTTPException(status_code=400, detail="Email is not yet verified") 23 | access_token = access_security.create_access_token(user.jwt_subject) 24 | refresh_token = refresh_security.create_refresh_token(user.jwt_subject) 25 | return RefreshToken(access_token=access_token, refresh_token=refresh_token) 26 | 27 | 28 | @router.post("/refresh") 29 | async def refresh( 30 | auth: JwtAuthorizationCredentials = Security(refresh_security) 31 | ) -> AccessToken: 32 | """Return a new access token from a refresh token.""" 33 | access_token = access_security.create_access_token(subject=auth.subject) 34 | return AccessToken(access_token=access_token) 35 | -------------------------------------------------------------------------------- /myserver/app.py: -------------------------------------------------------------------------------- 1 | """Server app config.""" 2 | 3 | from contextlib import asynccontextmanager 4 | 5 | from fastapi import FastAPI 6 | from beanie import init_beanie 7 | from motor.motor_asyncio import AsyncIOMotorClient 8 | from starlette.middleware.cors import CORSMiddleware 9 | 10 | from myserver.config import CONFIG 11 | from myserver.models.user import User 12 | 13 | 14 | DESCRIPTION = """ 15 | This API powers whatever I want to make 16 | 17 | It supports: 18 | 19 | - Account sign-up and management 20 | - Something really cool that will blow your socks off 21 | """ 22 | 23 | 24 | @asynccontextmanager 25 | async def lifespan(app: FastAPI): # type: ignore 26 | """Initialize application services.""" 27 | app.db = AsyncIOMotorClient(CONFIG.mongo_uri).account # type: ignore[attr-defined] 28 | await init_beanie(app.db, document_models=[User]) # type: ignore[arg-type,attr-defined] 29 | print("Startup complete") 30 | yield 31 | print("Shutdown complete") 32 | 33 | 34 | app = FastAPI( 35 | title="My Server", 36 | description=DESCRIPTION, 37 | version="0.1.0", 38 | contact={ 39 | "name": "Hello World Jr", 40 | "url": "https://myserver.dev", 41 | "email": "helloworld@myserver.dev", 42 | }, 43 | license_info={ 44 | "name": "MIT", 45 | "url": "https://github.com/flyinactor91/fastapi-beanie-jwt/blob/main/LICENSE", 46 | }, 47 | lifespan=lifespan, 48 | ) 49 | 50 | 51 | app.add_middleware( 52 | CORSMiddleware, 53 | allow_origins=["*"], 54 | allow_credentials=True, 55 | allow_methods=["*"], 56 | allow_headers=["*"], 57 | ) 58 | -------------------------------------------------------------------------------- /myserver/routes/mail.py: -------------------------------------------------------------------------------- 1 | """Email router.""" 2 | 3 | from datetime import datetime, UTC 4 | 5 | from fastapi import APIRouter, Body, HTTPException, Response 6 | from pydantic import EmailStr 7 | 8 | from myserver.models.user import User 9 | from myserver.jwt import access_security, user_from_token 10 | from myserver.util.mail import send_verification_email 11 | 12 | 13 | router = APIRouter(prefix="/mail", tags=["Mail"]) 14 | 15 | 16 | @router.post("/verify") 17 | async def request_verification_email( 18 | email: EmailStr = Body(..., embed=True) 19 | ) -> Response: 20 | """Send the user a verification email.""" 21 | user = await User.by_email(email) 22 | if user is None: 23 | raise HTTPException(404, "No user found with that email") 24 | if user.email_confirmed_at is not None: 25 | raise HTTPException(400, "Email is already verified") 26 | if user.disabled: 27 | raise HTTPException(400, "Your account is disabled") 28 | token = access_security.create_access_token(user.jwt_subject) 29 | await send_verification_email(email, token) 30 | return Response(status_code=200) 31 | 32 | 33 | @router.post("/verify/{token}") 34 | async def verify_email(token: str) -> Response: 35 | """Verify the user's email with the supplied token.""" 36 | user = await user_from_token(token) 37 | if user is None: 38 | raise HTTPException(404, "No user found with that email") 39 | if user.email_confirmed_at is not None: 40 | raise HTTPException(400, "Email is already verified") 41 | if user.disabled: 42 | raise HTTPException(400, "Your account is disabled") 43 | user.email_confirmed_at = datetime.now(tz=UTC) 44 | await user.save() 45 | return Response(status_code=200) 46 | -------------------------------------------------------------------------------- /myserver/util/mail.py: -------------------------------------------------------------------------------- 1 | """Mail server config.""" 2 | 3 | from fastapi_mail import FastMail, ConnectionConfig, MessageSchema, MessageType 4 | 5 | from myserver.config import CONFIG 6 | 7 | mail_conf = ConnectionConfig( 8 | MAIL_USERNAME=CONFIG.mail_username, 9 | MAIL_PASSWORD=CONFIG.mail_password, 10 | MAIL_FROM=CONFIG.mail_sender, 11 | MAIL_PORT=CONFIG.mail_port, 12 | MAIL_SERVER=CONFIG.mail_server, 13 | MAIL_STARTTLS=True, 14 | MAIL_SSL_TLS=True, 15 | USE_CREDENTIALS=True, 16 | ) 17 | 18 | mail = FastMail(mail_conf) 19 | 20 | 21 | async def send_verification_email(email: str, token: str) -> None: 22 | """Send user verification email.""" 23 | # Change this later to public endpoint 24 | url = CONFIG.root_url + "/mail/verify/" + token 25 | if CONFIG.mail_console: 26 | print("POST to " + url) 27 | else: 28 | message = MessageSchema( 29 | recipients=[email], 30 | subject="MyServer Email Verification", 31 | body=f"Welcome to MyServer! We just need to verify your email to begin: {url}", 32 | subtype=MessageType.plain, 33 | ) 34 | await mail.send_message(message) 35 | 36 | 37 | async def send_password_reset_email(email: str, token: str) -> None: 38 | """Send password reset email.""" 39 | # Change this later to public endpoint 40 | url = CONFIG.root_url + "/register/reset-password/" + token 41 | if CONFIG.mail_console: 42 | print("POST to " + url) 43 | else: 44 | message = MessageSchema( 45 | recipients=[email], 46 | subject="MyServer Password Reset", 47 | body=f"Click the link to reset your MyServer account password: {url}\nIf you did not request this, please ignore this email", 48 | subtype=MessageType.plain, 49 | ) 50 | await mail.send_message(message) 51 | -------------------------------------------------------------------------------- /myserver/models/user.py: -------------------------------------------------------------------------------- 1 | """User models.""" 2 | 3 | from datetime import datetime 4 | from typing import Annotated, Any, Optional 5 | 6 | from beanie import Document, Indexed 7 | from pydantic import BaseModel, EmailStr 8 | 9 | 10 | class UserAuth(BaseModel): 11 | """User register and login auth.""" 12 | 13 | email: EmailStr 14 | password: str 15 | 16 | 17 | class UserUpdate(BaseModel): 18 | """Updatable user fields.""" 19 | 20 | email: EmailStr | None = None 21 | 22 | # User information 23 | first_name: str | None = None 24 | last_name: str | None = None 25 | 26 | 27 | class UserOut(UserUpdate): 28 | """User fields returned to the client.""" 29 | 30 | email: Annotated[str, Indexed(EmailStr, unique=True)] 31 | disabled: bool = False 32 | 33 | 34 | class User(Document, UserOut): 35 | """User DB representation.""" 36 | 37 | password: str 38 | email_confirmed_at: datetime | None = None 39 | 40 | def __repr__(self) -> str: 41 | return f"" 42 | 43 | def __str__(self) -> str: 44 | return self.email 45 | 46 | def __hash__(self) -> int: 47 | return hash(self.email) 48 | 49 | def __eq__(self, other: object) -> bool: 50 | if isinstance(other, User): 51 | return self.email == other.email 52 | return False 53 | 54 | @property 55 | def created(self) -> datetime | None: 56 | """Datetime user was created from ID.""" 57 | return self.id.generation_time if self.id else None 58 | 59 | @property 60 | def jwt_subject(self) -> dict[str, Any]: 61 | """JWT subject fields.""" 62 | return {"username": self.email} 63 | 64 | @classmethod 65 | async def by_email(cls, email: str) -> Optional["User"]: 66 | """Get a user by email.""" 67 | return await cls.find_one(cls.email == email) 68 | 69 | def update_email(self, new_email: str) -> None: 70 | """Update email logging and replace.""" 71 | # Add any pre-checks here 72 | self.email = new_email 73 | -------------------------------------------------------------------------------- /myserver/routes/register.py: -------------------------------------------------------------------------------- 1 | """Registration router.""" 2 | 3 | from fastapi import APIRouter, Body, HTTPException, Response 4 | from pydantic import EmailStr 5 | 6 | from myserver.models.user import User, UserAuth, UserOut 7 | from myserver.jwt import access_security, user_from_token 8 | from myserver.util.mail import send_password_reset_email 9 | from myserver.util.password import hash_password 10 | 11 | router = APIRouter(prefix="/register", tags=["Register"]) 12 | 13 | embed = Body(..., embed=True) 14 | 15 | 16 | @router.post("", response_model=UserOut) 17 | async def user_registration(user_auth: UserAuth): # type: ignore[no-untyped-def] 18 | """Create a new user.""" 19 | user = await User.by_email(user_auth.email) 20 | if user is not None: 21 | raise HTTPException(409, "User with that email already exists") 22 | hashed = hash_password(user_auth.password) 23 | user = User(email=user_auth.email, password=hashed) 24 | await user.create() 25 | return user 26 | 27 | 28 | @router.post("/forgot-password") 29 | async def forgot_password(email: EmailStr = embed) -> Response: 30 | """Send password reset email.""" 31 | user = await User.by_email(email) 32 | if user is None: 33 | raise HTTPException(404, "No user found with that email") 34 | if user.email_confirmed_at is not None: 35 | raise HTTPException(400, "Email is already verified") 36 | if user.disabled: 37 | raise HTTPException(400, "Your account is disabled") 38 | token = access_security.create_access_token(user.jwt_subject) 39 | await send_password_reset_email(email, token) 40 | return Response(status_code=200) 41 | 42 | 43 | @router.post("/reset-password/{token}", response_model=UserOut) 44 | async def reset_password(token: str, password: str = embed): # type: ignore[no-untyped-def] 45 | """Reset user password from token value.""" 46 | user = await user_from_token(token) 47 | if user is None: 48 | raise HTTPException(404, "No user found with that email") 49 | if user.email_confirmed_at is None: 50 | raise HTTPException(400, "Email is not yet verified") 51 | if user.disabled: 52 | raise HTTPException(400, "Your account is disabled") 53 | user.password = hash_password(password) 54 | await user.save() 55 | return user 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | # .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /tests/routes/test_user.py: -------------------------------------------------------------------------------- 1 | """User information tests.""" 2 | 3 | import pytest 4 | from httpx import AsyncClient 5 | 6 | from tests.data import add_empty_user 7 | from tests.util import auth_headers 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_user_get(client: AsyncClient) -> None: 12 | """Test user endpoint returns authorized user.""" 13 | email = await add_empty_user() 14 | auth = await auth_headers(client, email) 15 | resp = await client.get("/user", headers=auth) 16 | assert resp.status_code == 200 17 | data = resp.json() 18 | assert data["email"] == email 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_user_update(client: AsyncClient) -> None: 23 | """Test updating user fields.""" 24 | email = await add_empty_user() 25 | name, key = "Empty", "first_name" 26 | auth = await auth_headers(client, email) 27 | resp = await client.get("/user", headers=auth) 28 | assert resp.status_code == 200 29 | data = resp.json() 30 | assert data["email"] == email 31 | assert data[key] is None 32 | # Update user 33 | name = "Tester" 34 | resp = await client.patch("/user", headers=auth, json={key: name}) 35 | assert resp.status_code == 200 36 | data = resp.json() 37 | assert data["email"] == email 38 | assert data[key] == name 39 | # Check persistance 40 | resp = await client.get("/user", headers=auth) 41 | assert resp.status_code == 200 42 | data = resp.json() 43 | assert data["email"] == email 44 | assert data[key] == name 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_user_update_email(client: AsyncClient) -> None: 49 | """Test updating user email.""" 50 | email = await add_empty_user() 51 | new_email, key = "test_replace@test.io", "email" 52 | auth = await auth_headers(client, email) 53 | resp = await client.get("/user", headers=auth) 54 | assert resp.status_code == 200 55 | data = resp.json() 56 | assert data[key] == email 57 | # Update email 58 | resp = await client.patch("/user", headers=auth, json={key: new_email}) 59 | assert resp.status_code == 200 60 | data = resp.json() 61 | assert data[key] == new_email 62 | # Check old email 63 | resp = await client.get("/user", headers=auth) 64 | assert resp.status_code == 404 65 | # Check persistance 66 | auth = await auth_headers(client, new_email, email) 67 | resp = await client.get("/user", headers=auth) 68 | assert resp.status_code == 200 69 | data = resp.json() 70 | assert data[key] == new_email 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_user_delete(client: AsyncClient) -> None: 75 | """Test deleting a user from the database.""" 76 | email = await add_empty_user() 77 | auth = await auth_headers(client, email) 78 | resp = await client.get("/user", headers=auth) 79 | assert resp.status_code == 200 80 | data = resp.json() 81 | assert data["email"] == email 82 | # Delete user 83 | resp = await client.delete("/user", headers=auth) 84 | assert resp.status_code == 204 85 | # Check deletion 86 | resp = await client.get("/user", headers=auth) 87 | assert resp.status_code == 404 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-beanie-jwt 2 | 3 | [![python](https://img.shields.io/badge/Python-3.11-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) 4 | [![FastAPI](https://img.shields.io/badge/FastAPI-0.104.1-009688.svg?style=flat&logo=FastAPI&logoColor=white)](https://fastapi.tiangolo.com) 5 | [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 6 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 8 | 9 | Sample FastAPI server with JWT auth and Beanie ODM 10 | 11 | ## Intro 12 | 13 | This starter app provides a basic account API on top of a [MongoDB]() store with the following features: 14 | 15 | - Registration 16 | - Email verification 17 | - Password reset 18 | - JWT auth login and refresh 19 | - User model CRUD 20 | 21 | It's built on top of these libraries to provide those features: 22 | 23 | - [FastAPI]() - Python async micro framework built on [Starlette]() and [PyDantic]() 24 | - [Beanie ODM]() - Async [MongoDB]() object-document mapper built on [PyDantic]() 25 | - [fastapi-jwt]() - JWT auth for [FastAPI]() 26 | - [fastapi-mail]() - Mail server manager for [FastAPI]() 27 | 28 | ## Setup 29 | 30 | This codebase was written for Python 3.11 and above. Don't forget about a venv as well. The `python` commands below assume you're pointing to your desired Python3 target. 31 | 32 | First we'll need to install our requirements. 33 | 34 | ```bash 35 | python -m pip install -e . 36 | ``` 37 | 38 | Before we run the server, there is one config variable you'll need to generate the password salt. To do this, just run the script in this repo. 39 | 40 | ```bash 41 | python gen_salt.py 42 | ``` 43 | 44 | There are other settings in `config.py` and the included `.env` file. Assuming you've changed the SALT value, everything should run as-is if there is a local [MongoDB]() instance running ([see below](#test) for a Docker solution). Any email links will be printed to the console by default. 45 | 46 | ## Run 47 | 48 | This sample uses [uvicorn]() as our ASGI web server. This allows us to run our server code in a much more robust and configurable environment than the development server. For example, ASGI servers let you run multiple workers that recycle themselves after a set amount of time or number of requests. 49 | 50 | ```bash 51 | uvicorn myserver.main:app --reload --port 8080 52 | ``` 53 | 54 | You're API should now be available at http://localhost:8080 55 | 56 | ## Develop 57 | 58 | This codebase is uses [mypy]() for type checking and [ruff]() for everything else. Install both with the dev tag. 59 | 60 | ```bash 61 | python -m pip install -e .[dev] 62 | ``` 63 | 64 | To run the type checker: 65 | 66 | ```bash 67 | mypy myserver 68 | ``` 69 | 70 | To run the linter and code formatter: 71 | 72 | ```bash 73 | ruff check myserver 74 | ruff format myserver 75 | ``` 76 | 77 | ## Test 78 | 79 | The sample app also comes with a test suite to get you started. 80 | 81 | Make sure to install the requirements found in the test folder before trying to run the tests. 82 | 83 | ```bash 84 | python -m pip install -e .[test] 85 | ``` 86 | 87 | The tests need access to a [MongoDB]() store that is emptied at the end of each test. The easiest way to do this is to run a Mongo container in the background. 88 | 89 | ```bash 90 | docker run -d -p 27017:27017 mongo:7 91 | ``` 92 | 93 | You can also connect to a remote server if you're running tests in a CI/CD pipeline. Just set the `TEST_MONGO_URI` in the environment. This value defaults to localhost and is only checked in the test suite. It should **never** use your `MONGO_URI`. 94 | 95 | Then just run the test suite. 96 | 97 | ```bash 98 | pytest 99 | ``` 100 | 101 | [MongoDB]: https://www.mongodb.com "MongoDB NoSQL homepage" 102 | [FastAPI]: https://fastapi.tiangolo.com "FastAPI web framework" 103 | [Beanie ODM]: https://roman-right.github.io/beanie/ "Beanie object-document mapper" 104 | [Starlette]: https://www.starlette.io "Starlette web framework" 105 | [PyDantic]: https://pydantic-docs.helpmanual.io "PyDantic model validation" 106 | [fastapi-jwt]: https://github.com/k4black/fastapi-jwt "JWT auth for FastAPI" 107 | [fastapi-mail]: https://github.com/sabuhish/fastapi-mail "FastAPI mail server" 108 | [uvicorn]: https://www.uvicorn.org "Uvicorn ASGI web server" 109 | [mypy]: https://www.mypy-lang.org "mypy Python type checker" 110 | [ruff]: https://docs.astral.sh/ruff/ "Ruff code linter and formatter" --------------------------------------------------------------------------------