Skip to content

Commit 5b6229f

Browse files
authored
Merge pull request #63 from igorbenav/token-refresh
Token refresh
2 parents 6c6ed34 + a2de1f6 commit 5b6229f

File tree

6 files changed

+111
-14
lines changed

6 files changed

+111
-14
lines changed

README.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- ⚡️ Fully async
4949
- 🚀 Pydantic V2 and SQLAlchemy 2.0
5050
- 🔐 User authentication with JWT
51+
- Cookie based refresh token
5152
- 🏬 Easy redis caching
5253
- 👜 Easy client-side caching
5354
- 🚦 ARQ integration for task queue
@@ -92,7 +93,8 @@
9293
9. [More Advanced Caching](#59-more-advanced-caching)
9394
10. [ARQ Job Queues](#510-arq-job-queues)
9495
11. [Rate Limiting](#511-rate-limiting)
95-
12. [Running](#512-running)
96+
12. [JWT Authentication](#512-jwt-authentication)
97+
13. [Running](#512-running)
9698
6. [Running in Production](#6-running-in-production)
9799
1. [Uvicorn Workers with Gunicorn](#61-uvicorn-workers-with-gunicorn)
98100
2. [Running With NGINX](#62-running-with-nginx)
@@ -156,6 +158,7 @@ And then create in `.env`:
156158
SECRET_KEY= # result of openssl rand -hex 32
157159
ALGORITHM= # pick an algorithm, default HS256
158160
ACCESS_TOKEN_EXPIRE_MINUTES= # minutes until token expires, default 30
161+
REFRESH_TOKEN_EXPIRE_DAYS= # days until token expires, default 7
159162
```
160163

161164
Then for the first admin user:
@@ -1251,7 +1254,47 @@ Note that for flexibility (since this is a boilerplate), it's not necessary to p
12511254
> [!WARNING]
12521255
> If a user does not have a `tier` or the tier does not have a defined `rate limit` for the path and the token is still passed to the request, the default `limit` and `period` will be used, this will be saved in `app/logs`.
12531256
1254-
### 5.12 Running
1257+
### 5.12 JWT Authentication
1258+
#### 5.12.1 Details
1259+
The JWT in this boilerplate is created in the following way:
1260+
1. **JWT Access Tokens:** how you actually access protected resources is passing this token in the request header.
1261+
2. **Refresh Tokens:** you use this type of token to get an `access token`, which you'll use to access protected resources.
1262+
1263+
The `access token` is short lived (default 30 minutes) to reduce the damage of a potential leak. The `refresh token`, on the other hand, is long lived (default 7 days), and you use it to renew your `access token` without the need to provide username and password every time it expires.
1264+
1265+
Since the `refresh token` lasts for a longer time, it's stored as a cookie in a secure way:
1266+
1267+
```python
1268+
# app/api/v1/login
1269+
1270+
...
1271+
response.set_cookie(
1272+
key="refresh_token",
1273+
value=refresh_token,
1274+
httponly=True, # Prevent access through JavaScript
1275+
secure=True, # Ensure cookie is sent over HTTPS only
1276+
samesite='Lax', # Default to Lax for reasonable balance between security and usability
1277+
max_age=<number_of_seconds> # Set a max age for the cookie
1278+
)
1279+
...
1280+
```
1281+
1282+
You may change it to suit your needs. The possible options for `samesite` are:
1283+
- `Lax`: Cookies will be sent in top-level navigations (like clicking on a link to go to another site), but not in API requests or images loaded from other sites.
1284+
- `Strict`: Cookies will be sent in top-level navigations (like clicking on a link to go to another site), but not in API requests or images loaded from other sites.
1285+
- `None`: Cookies will be sent with both same-site and cross-site requests.
1286+
1287+
#### 5.12.2 Usage
1288+
What you should do with the client is:
1289+
- `Login`: Send credentials to `/api/v1/login`. Store the returned access token in memory for subsequent requests.
1290+
- `Accessing Protected Routes`: Include the access token in the Authorization header.
1291+
- `Token Renewal`: On access token expiry, the front end should automatically call `/api/v1/refresh` for a new token.
1292+
- `Login Again`: If refresh token is expired, credentials should be sent to `/api/v1/login` again, storing the new access token in memory.
1293+
- `Logout`: Call /api/v1/logout to end the session securely.
1294+
1295+
This authentication setup in the provides a robust, secure, and user-friendly way to handle user sessions in your API applications.
1296+
1297+
### 5.13 Running
12551298
If you are using docker compose, just running the following command should ensure everything is working:
12561299
```sh
12571300
docker compose up

src/app/api/v1/login.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
from typing import Annotated, Dict
2-
from datetime import timedelta
2+
from datetime import timedelta, datetime, timezone
33

4-
from fastapi import Depends
4+
from fastapi import Response, Request, Depends
55
from fastapi.security import OAuth2PasswordRequestForm
66
from sqlalchemy.ext.asyncio import AsyncSession
77
import fastapi
88

9+
from app.core.config import settings
910
from app.core.db.database import async_get_db
10-
from app.core.schemas import Token
11-
from app.core.security import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, authenticate_user
1211
from app.core.exceptions.http_exceptions import UnauthorizedException
12+
from app.core.schemas import Token
13+
from app.core.security import (
14+
ACCESS_TOKEN_EXPIRE_MINUTES,
15+
create_access_token,
16+
authenticate_user,
17+
create_refresh_token,
18+
verify_token
19+
)
1320

1421
router = fastapi.APIRouter(tags=["login"])
1522

1623
@router.post("/login", response_model=Token)
1724
async def login_for_access_token(
25+
response: Response,
1826
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
1927
db: Annotated[AsyncSession, Depends(async_get_db)]
2028
) -> Dict[str, str]:
@@ -30,5 +38,37 @@ async def login_for_access_token(
3038
access_token = await create_access_token(
3139
data={"sub": user["username"]}, expires_delta=access_token_expires
3240
)
41+
42+
refresh_token = await create_refresh_token(data={"sub": user["username"]})
43+
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
44+
45+
response.set_cookie(
46+
key="refresh_token",
47+
value=refresh_token,
48+
httponly=True,
49+
secure=True,
50+
samesite='Lax',
51+
max_age=max_age
52+
)
3353

34-
return {"access_token": access_token, "token_type": "bearer"}
54+
return {
55+
"access_token": access_token,
56+
"token_type": "bearer"
57+
}
58+
59+
60+
@router.post("/refresh")
61+
async def refresh_access_token(
62+
request: Request,
63+
db: AsyncSession = Depends(async_get_db)
64+
) -> Dict[str, str]:
65+
refresh_token = request.cookies.get("refresh_token")
66+
if not refresh_token:
67+
raise UnauthorizedException("Refresh token missing.")
68+
69+
user_data = await verify_token(refresh_token, db)
70+
if not user_data:
71+
raise UnauthorizedException("Invalid refresh token.")
72+
73+
new_access_token = await create_access_token(data={"sub": user_data.username_or_email})
74+
return {"access_token": new_access_token, "token_type": "bearer"}

src/app/api/v1/logout.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from typing import Dict
22

3-
from datetime import datetime
4-
5-
from fastapi import APIRouter, Depends
3+
from fastapi import APIRouter, Response, Depends
64
from sqlalchemy.ext.asyncio import AsyncSession
75
from jose import JWTError
86

@@ -14,11 +12,14 @@
1412

1513
@router.post("/logout")
1614
async def logout(
17-
token: str = Depends(oauth2_scheme),
15+
response: Response,
16+
access_token: str = Depends(oauth2_scheme),
1817
db: AsyncSession = Depends(async_get_db)
1918
) -> Dict[str, str]:
2019
try:
21-
await blacklist_token(token=token, db=db)
20+
await blacklist_token(token=access_token, db=db)
21+
response.delete_cookie(key="refresh_token")
22+
2223
return {"message": "Logged out successfully"}
2324

2425
except JWTError:

src/app/core/config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ class AppSettings(BaseSettings):
1616

1717
class CryptSettings(BaseSettings):
1818
SECRET_KEY: str = config("SECRET_KEY")
19-
ALGORITHM: str = config("ALGORITHM")
20-
ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES")
19+
ALGORITHM: str = config("ALGORITHM", default="HS256")
20+
ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES", default=30)
21+
REFRESH_TOKEN_EXPIRE_DAYS: int = config("REFRESH_TOKEN_EXPIRE_DAYS", default=7)
2122

2223

2324
class DatabaseSettings(BaseSettings):

src/app/core/db/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class TimestampMixin:
1111
created_at: datetime = Column(DateTime, default=datetime.utcnow, server_default=text("current_timestamp(0)"))
1212
updated_at: datetime = Column(DateTime, nullable=True, onupdate=datetime.utcnow, server_default=text("current_timestamp(0)"))
1313

14+
1415
class SoftDeleteMixin:
1516
deleted_at: datetime = Column(DateTime, nullable=True)
1617
is_deleted: bool = Column(Boolean, default=False)

src/app/core/security.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SECRET_KEY = settings.SECRET_KEY
1515
ALGORITHM = settings.ALGORITHM
1616
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
17+
REFRESH_TOKEN_EXPIRE_DAYS = settings.REFRESH_TOKEN_EXPIRE_DAYS
1718

1819
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login")
1920
crypt_context = CryptContext(schemes=["sha256_crypt"])
@@ -50,6 +51,16 @@ async def create_access_token(data: dict[str, Any], expires_delta: timedelta | N
5051
encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
5152
return encoded_jwt
5253

54+
async def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
55+
to_encode = data.copy()
56+
if expires_delta:
57+
expire = datetime.utcnow() + expires_delta
58+
else:
59+
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
60+
to_encode.update({"exp": expire})
61+
encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
62+
return encoded_jwt
63+
5364
async def verify_token(token: str, db: AsyncSession) -> TokenData | None:
5465
"""
5566
Verify a JWT token and return TokenData if valid.

0 commit comments

Comments
 (0)