├── .gitignore ├── README.md ├── api ├── __init__.py └── auth_route.py ├── config ├── __init__.py ├── auth.py └── settings.py ├── dev.env ├── main.py ├── migrations.py ├── models ├── __init__.py └── user.py ├── requirements.txt └── schemas ├── __init__.py └── user.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | venv 4 | __pycache__ 5 | migrations 6 | *.ini 7 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **FastAPI JWT Authentication** 2 | 3 | We create an access token and a refresh token. But there is a more secure way to implement this using Refresh Tokens. 4 | 5 | **Refresh Token:** It is a unique token that is used to obtain additional access tokens. This allows you to have short-lived access tokens without having to collect credentials every time one expires. 6 | 7 | [▶️ Watch Full Video](https://youtu.be/LgFxZhdhgeg?si=psrPGhi3Yg-zCdAh) 8 | 9 | > Environment setup 10 | ``` 11 | > python -m venv venv 12 | > venv\Scripts\activate 13 | > python -m pip install --upgrade pip 14 | > pip install -r requirements.txt 15 | ``` 16 | 17 | > Environment Variables 18 | ``` 19 | > rename dev.env to .env 20 | ``` 21 | 22 | > Database Migrations 23 | ``` 24 | > aerich init -t migrations.settings 25 | > aerich init-db 26 | ``` 27 | 28 | > Run Application 29 | ``` 30 | > python main.py 31 | ``` -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoprocedures/fastapi-jwt/60409155e125477b3cf3eb3c0c4ff1be256bd7a9/api/__init__.py -------------------------------------------------------------------------------- /api/auth_route.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | from config.auth import verified_user, authorize, pwd_context, create_access_jwt, create_refresh_jwt 3 | from schemas.user import User, UserGet, UserPost, UserLogin 4 | from fastapi import Depends, status, HTTPException 5 | 6 | auth_router = APIRouter(prefix='/api/v1', tags=['AUTH']) 7 | 8 | 9 | @auth_router.post('/register', status_code=status.HTTP_201_CREATED) 10 | async def register(body: UserPost): 11 | # encrypt password 12 | body.password_hash = pwd_context.hash(body.password_hash) 13 | # turn play_load into dict 14 | data = body.model_dump(by_alias=False, exclude_unset=True) 15 | # check if email is taken 16 | existing = await User.filter(email=body.email).exists() 17 | if existing: 18 | raise HTTPException( 19 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 20 | detail="email already taken" 21 | ) 22 | # create the user 23 | user_obj = await User.create(**data) 24 | # return created user 25 | user = await UserGet.from_tortoise_orm(user_obj) 26 | # get the created user:id 27 | user_id = user.model_dump()['id'] 28 | return user_id 29 | 30 | 31 | @auth_router.post('/login') 32 | async def login(body: UserLogin): 33 | error = HTTPException( 34 | status_code=status.HTTP_401_UNAUTHORIZED, 35 | detail='wrong credentials' 36 | ) 37 | # check if email exists 38 | user = await User.filter(email=body.email).first() 39 | if not user: 40 | raise error 41 | # check if password matches 42 | matches = pwd_context.verify(body.password, user.password_hash) 43 | if not matches: 44 | raise error 45 | # create jwt access token 46 | data = {'user_name': user.email} 47 | access_tkn = create_access_jwt(data) 48 | # create jwt refresh token 49 | refresh_tkn = create_refresh_jwt(data) 50 | # store the refresh token in memory||database|| any storage 51 | # in my case I am storing in users-table 52 | await User.filter(email=body.email).update(**{'refresh_token': refresh_tkn}) 53 | 54 | return { 55 | 'message': 'Login Successful', 56 | 'first_name': user.first_name, 57 | 'last_name': user.last_name, 58 | 'email': user.email, 59 | 'access_token': access_tkn, 60 | 'refresh_token': refresh_tkn, 61 | 'type': 'bearer' 62 | } 63 | 64 | 65 | @auth_router.post('/refresh_token') 66 | async def refresh(token_data: dict = Depends(authorize)): 67 | return token_data 68 | 69 | 70 | @auth_router.get('/data') 71 | async def protected_data(user: User = Depends(verified_user)): 72 | return {'status': 'authorized', 'email': user.email} 73 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoprocedures/fastapi-jwt/60409155e125477b3cf3eb3c0c4ff1be256bd7a9/config/__init__.py -------------------------------------------------------------------------------- /config/auth.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | from jose import jwt 3 | from datetime import datetime 4 | from config.settings import Config 5 | from fastapi import Depends, HTTPException, status 6 | from jose.exceptions import JWTError 7 | from fastapi.security import OAuth2PasswordBearer 8 | from models.user import User 9 | 10 | pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') 11 | oauth_scheme = OAuth2PasswordBearer(tokenUrl='/token') 12 | 13 | error = HTTPException( 14 | status_code=status.HTTP_401_UNAUTHORIZED, 15 | detail='invalid authorization credentials' 16 | ) 17 | 18 | 19 | def create_access_jwt(data: dict): 20 | data['exp'] = datetime.utcnow() + Config.JWT_ACCESS_EXP 21 | data['mode'] = 'access_token' 22 | return jwt.encode(data, Config.SECRET, Config.ALGORITHM) 23 | 24 | 25 | def create_refresh_jwt(data: dict): 26 | data['exp'] = datetime.utcnow() + Config.JWT_REFRESH_EXP 27 | data['mode'] = 'refresh_token' 28 | return jwt.encode(data, Config.SECRET, Config.ALGORITHM) 29 | 30 | 31 | async def authorize(token: str = Depends(oauth_scheme)) -> dict: 32 | # validate the refresh jwt token 33 | try: 34 | data = jwt.decode(token, Config.SECRET, Config.ALGORITHM) 35 | # check if "mode": "refresh_token" 36 | if 'user_name' not in data and 'mode' not in data: 37 | raise error 38 | if data['mode'] != 'refresh_token': 39 | raise error 40 | # check if user exists 41 | user = await User.filter(email=data['user_name']).first() 42 | if not user or token != user.refresh_token: 43 | raise error 44 | # generate new refresh token and update user 45 | data = {'user_name': user.email} 46 | refresh_tkn = create_refresh_jwt(data) 47 | await User.filter(email=user.email).update(**{'refresh_token': refresh_tkn}) 48 | # generate new access token 49 | access_tkn = create_access_jwt(data) 50 | return { 51 | 'access_token': access_tkn, 52 | 'refresh_token': refresh_tkn, 53 | 'type': 'bearer' 54 | } 55 | except JWTError: 56 | raise error 57 | 58 | 59 | async def verified_user(token: str = Depends(oauth_scheme)) -> User: 60 | # validate the access jwt token 61 | try: 62 | data = jwt.decode(token, Config.SECRET, Config.ALGORITHM) 63 | # check if "mode": "refresh_token" 64 | if 'user_name' not in data and 'mode' not in data: 65 | raise error 66 | if data['mode'] != 'access_token': 67 | raise error 68 | # check if user exists 69 | user = await User.filter(email=data['user_name']).first() 70 | if not user: 71 | raise error 72 | 73 | return user 74 | except JWTError: 75 | raise error 76 | -------------------------------------------------------------------------------- /config/settings.py: -------------------------------------------------------------------------------- 1 | from dotenv import find_dotenv, dotenv_values 2 | from typing import List 3 | from datetime import timedelta 4 | 5 | env_path = find_dotenv() 6 | 7 | config = dotenv_values(env_path) 8 | 9 | 10 | class Config: 11 | DB_URL: str = config['DB_URL'] 12 | DB_MODELS: List[str] = ['models.user'] 13 | SECRET: str = config['JWT_SECRET_KEY'] # '' 14 | ALGORITHM: str = config['JWT_ALGORITHM'] 15 | JWT_ACCESS_EXP: timedelta = timedelta(days=float(config['JWT_ACCESS_TOKEN_EXP_DAYS'])) 16 | JWT_REFRESH_EXP: timedelta = timedelta(days=float(config['JWT_REFRESH_TOKEN_EXP_DAYS'])) 17 | -------------------------------------------------------------------------------- /dev.env: -------------------------------------------------------------------------------- 1 | # DATABASE CONFIGURATION 2 | DB_HOST = 3 | DB_PORT = 4 | DB_USER = 5 | DB_PASS = 6 | DB_SCHEMA = 7 | DB_URL = mysql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_SCHEMA} 8 | 9 | # JWT SETTINGS 10 | JWT_SECRET_KEY = any-secret-key 11 | JWT_ALGORITHM = HS256 12 | JWT_ACCESS_TOKEN_EXP_DAYS = 1 13 | JWT_REFRESH_TOKEN_EXP_DAYS = 365 -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from tortoise.contrib.fastapi import register_tortoise 3 | from config.settings import Config 4 | from api.auth_route import auth_router 5 | import uvicorn 6 | 7 | app = FastAPI() 8 | app.include_router(auth_router) 9 | 10 | register_tortoise( 11 | app, 12 | db_url=Config.DB_URL, 13 | add_exception_handlers=True, 14 | generate_schemas=False, 15 | modules={'models': Config.DB_MODELS} 16 | ) 17 | 18 | if __name__ == "__main__": 19 | uvicorn.run("main:app", reload=True) 20 | -------------------------------------------------------------------------------- /migrations.py: -------------------------------------------------------------------------------- 1 | from config.settings import Config 2 | 3 | settings = { 4 | 'connections': {'default': Config.DB_URL}, 5 | 'apps': { 6 | 'models': { 7 | 'models': ['aerich', *Config.DB_MODELS], 8 | 'default_connection': 'default' 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoprocedures/fastapi-jwt/60409155e125477b3cf3eb3c0c4ff1be256bd7a9/models/__init__.py -------------------------------------------------------------------------------- /models/user.py: -------------------------------------------------------------------------------- 1 | from tortoise.models import Model 2 | from tortoise.fields import IntField, CharField, TextField 3 | 4 | 5 | class User(Model): 6 | id = IntField(pk=True) 7 | email = CharField(max_length=100, null=False, unique=True) 8 | first_name = CharField(max_length=50, null=True) 9 | last_name = CharField(max_length=50, null=True) 10 | password_hash = CharField(max_length=100, null=False) 11 | refresh_token = TextField(null=True) 12 | 13 | class Meta: 14 | table = 'users' 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn[standard] 3 | tortoise-orm 4 | aiomysql 5 | python-dotenv 6 | python-jose 7 | bcrypt==4.0.0 8 | passlib[bcrypt] 9 | pydantic[email] 10 | -------------------------------------------------------------------------------- /schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amoprocedures/fastapi-jwt/60409155e125477b3cf3eb3c0c4ff1be256bd7a9/schemas/__init__.py -------------------------------------------------------------------------------- /schemas/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, EmailStr, BaseModel 2 | from typing import Optional 3 | from models.user import User 4 | from tortoise.contrib.pydantic import pydantic_model_creator 5 | 6 | UserGet = pydantic_model_creator(User, name='User') 7 | 8 | 9 | class UserPost(BaseModel): 10 | email: EmailStr 11 | first_name: Optional[str] = Field(None, max_length=50) 12 | last_name: Optional[str] = Field(None, max_length=50) 13 | password_hash: str = Field(alias='password', min_length=8, max_length=20) 14 | 15 | 16 | class UserLogin(BaseModel): 17 | email: EmailStr 18 | password: str 19 | --------------------------------------------------------------------------------