Skip to content

Commit d9c978f

Browse files
authored
Merge pull request #52 from igorbenav/token-blacklist
Token blacklist
2 parents 09c539f + 8233774 commit d9c978f

File tree

14 files changed

+323
-159
lines changed

14 files changed

+323
-159
lines changed

README.md

Lines changed: 90 additions & 86 deletions
Large diffs are not rendered by default.

docker-compose.yml

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -45,48 +45,48 @@ services:
4545
volumes:
4646
- redis-data:/data
4747

48-
# #-------- uncomment to create first superuser --------
49-
# create_superuser:
50-
# build:
51-
# context: .
52-
# dockerfile: Dockerfile
53-
# env_file:
54-
# - ./src/.env
55-
# depends_on:
56-
# - db
57-
# - web
58-
# command: python -m src.scripts.create_first_superuser
59-
# volumes:
60-
# - ./src:/code/src
48+
# #-------- uncomment to create first superuser --------
49+
# create_superuser:
50+
# build:
51+
# context: .
52+
# dockerfile: Dockerfile
53+
# env_file:
54+
# - ./src/.env
55+
# depends_on:
56+
# - db
57+
# - web
58+
# command: python -m src.scripts.create_first_superuser
59+
# volumes:
60+
# - ./src:/code/src
6161

62-
# #-------- uncomment to run tests --------
63-
# # pytest:
64-
# # build:
65-
# # context: .
66-
# # dockerfile: Dockerfile
67-
# # env_file:
68-
# # - ./src/.env
69-
# # depends_on:
70-
# # - db
71-
# # - create_superuser
72-
# # - redis
73-
# # command: python -m pytest
74-
# # volumes:
75-
# # - ./src:/code/src
62+
#-------- uncomment to run tests --------
63+
# pytest:
64+
# build:
65+
# context: .
66+
# dockerfile: Dockerfile
67+
# env_file:
68+
# - ./src/.env
69+
# depends_on:
70+
# - db
71+
# - create_superuser
72+
# - redis
73+
# command: python -m pytest
74+
# volumes:
75+
# - ./src:/code/src
7676

77-
# #-------- uncomment to create first tier --------
78-
# create_tier:
79-
# build:
80-
# context: .
81-
# dockerfile: Dockerfile
82-
# env_file:
83-
# - ./src/.env
84-
# depends_on:
85-
# - db
86-
# - web
87-
# command: python -m src.scripts.create_first_tier
88-
# volumes:
89-
# - ./src:/code/src
77+
# #-------- uncomment to create first tier --------
78+
# create_tier:
79+
# build:
80+
# context: .
81+
# dockerfile: Dockerfile
82+
# env_file:
83+
# - ./src/.env
84+
# depends_on:
85+
# - db
86+
# - web
87+
# command: python -m src.scripts.create_first_tier
88+
# volumes:
89+
# - ./src:/code/src
9090

9191
volumes:
9292
postgres-data:

src/app/api/dependencies.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@
1111
Request
1212
)
1313

14+
from app.api.exceptions import credentials_exception, privileges_exception
1415
from app.core.database import async_get_db
16+
from app.core.logger import logging
1517
from app.core.models import TokenData
1618
from app.core.rate_limit import is_rate_limited
17-
from app.core.logger import logging
18-
from app.models.user import User
19-
from app.api.exceptions import credentials_exception, privileges_exception
20-
from app.crud.crud_users import crud_users
21-
from app.crud.crud_tier import crud_tiers
19+
from app.core.security import verify_token
2220
from app.crud.crud_rate_limit import crud_rate_limits
21+
from app.crud.crud_tier import crud_tiers
22+
from app.crud.crud_users import crud_users
23+
from app.models.user import User
2324
from app.schemas.rate_limit import sanitize_path
2425

25-
2626
logger = logging.getLogger(__name__)
2727

2828
DEFAULT_LIMIT = settings.DEFAULT_RATE_LIMIT_LIMIT
@@ -31,7 +31,7 @@
3131
async def get_current_user(
3232
token: Annotated[str, Depends(oauth2_scheme)],
3333
db: Annotated[AsyncSession, Depends(async_get_db)]
34-
) -> User:
34+
) -> dict:
3535
try:
3636
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
3737
username_or_email: str = payload.get("sub")
@@ -53,10 +53,29 @@ async def get_current_user(
5353
raise credentials_exception
5454

5555

56+
async def get_current_user(
57+
token: Annotated[str, Depends(oauth2_scheme)],
58+
db: Annotated[AsyncSession, Depends(async_get_db)]
59+
) -> dict:
60+
token_data = await verify_token(token, db)
61+
if token_data is None:
62+
raise credentials_exception
63+
64+
if "@" in token_data.username_or_email:
65+
user = await crud_users.get(db=db, email=token_data.username_or_email, is_deleted=False)
66+
else:
67+
user = await crud_users.get(db=db, username=token_data.username_or_email, is_deleted=False)
68+
69+
if user:
70+
return user
71+
72+
raise credentials_exception
73+
74+
5675
async def get_optional_user(
5776
request: Request,
5877
db: AsyncSession = Depends(async_get_db)
59-
) -> User | None:
78+
) -> dict | None:
6079
token = request.headers.get("Authorization")
6180
if not token:
6281
return None
@@ -66,7 +85,11 @@ async def get_optional_user(
6685
if token_type.lower() != 'bearer' or not token_value:
6786
return None
6887

69-
return await get_current_user(token_value, db)
88+
token_data = await verify_token(token_value, db)
89+
if token_data is None:
90+
return None
91+
92+
return await get_current_user(token_value, is_deleted=False, db=db)
7093

7194
except HTTPException as http_exc:
7295
if http_exc.status_code != 401:
@@ -75,10 +98,10 @@ async def get_optional_user(
7598

7699
except Exception as exc:
77100
logger.error(f"Unexpected error in get_optional_user: {exc}")
78-
return None
101+
return None
79102

80103

81-
async def get_current_superuser(current_user: Annotated[User, Depends(get_current_user)]) -> User:
104+
async def get_current_superuser(current_user: Annotated[User, Depends(get_current_user)]) -> dict:
82105
if not current_user["is_superuser"]:
83106
raise privileges_exception
84107

src/app/api/v1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import APIRouter
22

33
from app.api.v1.login import router as login_router
4+
from app.api.v1.logout import router as logout_router
45
from app.api.v1.users import router as users_router
56
from app.api.v1.posts import router as posts_router
67
from app.api.v1.tasks import router as tasks_router
@@ -9,6 +10,7 @@
910

1011
router = APIRouter(prefix="/v1")
1112
router.include_router(login_router)
13+
router.include_router(logout_router)
1214
router.include_router(users_router)
1315
router.include_router(posts_router)
1416
router.include_router(tasks_router)

src/app/api/v1/login.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ async def login_for_access_token(
1818
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
1919
db: Annotated[AsyncSession, Depends(async_get_db)]
2020
):
21-
2221
user = await authenticate_user(
2322
username_or_email=form_data.username,
2423
password=form_data.password,

src/app/api/v1/logout.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from datetime import datetime
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
from jose import jwt, JWTError
6+
7+
from app.core.security import oauth2_scheme, SECRET_KEY, ALGORITHM
8+
from app.core.database import async_get_db
9+
from app.crud.crud_token_blacklist import crud_token_blacklist
10+
from app.schemas.token_blacklist import TokenBlacklistCreate
11+
12+
router = APIRouter(tags=["login"])
13+
14+
@router.post("/logout")
15+
async def logout(
16+
token: str = Depends(oauth2_scheme),
17+
db: AsyncSession = Depends(async_get_db)
18+
):
19+
try:
20+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
21+
expires_at = datetime.fromtimestamp(payload.get("exp"))
22+
await crud_token_blacklist.create(
23+
db,
24+
object=TokenBlacklistCreate(
25+
**{"token": token, "expires_at": expires_at}
26+
)
27+
)
28+
return {"message": "Logged out successfully"}
29+
30+
except JWTError:
31+
raise HTTPException(
32+
status_code=status.HTTP_401_UNAUTHORIZED,
33+
detail="Invalid token",
34+
headers={"WWW-Authenticate": "Bearer"},
35+
)

src/app/core/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Token(BaseModel):
1616

1717

1818
class TokenData(BaseModel):
19-
username_or_email: str | None = None
19+
username_or_email: str
2020

2121

2222
class UUIDModel(BaseModel):

src/app/core/security.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
from sqlalchemy.ext.asyncio import AsyncSession
44
from passlib.context import CryptContext
5-
from jose import jwt
5+
from jose import jwt, JWTError
66
from fastapi.security import OAuth2PasswordBearer
77

8-
from app.crud.crud_users import crud_users
98
from app.core.config import settings
10-
9+
from app.core.models import TokenData
10+
from app.crud.crud_token_blacklist import crud_token_blacklist
11+
from app.crud.crud_users import crud_users
1112

1213
SECRET_KEY = settings.SECRET_KEY
1314
ALGORITHM = settings.ALGORITHM
@@ -45,3 +46,33 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None
4546
to_encode.update({"exp": expire})
4647
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
4748
return encoded_jwt
49+
50+
async def verify_token(token: str, db: AsyncSession) -> TokenData | None:
51+
"""
52+
Verify a JWT token and return TokenData if valid.
53+
54+
Parameters
55+
----------
56+
token: str
57+
The JWT token to be verified.
58+
db: AsyncSession
59+
Database session for performing database operations.
60+
61+
Returns
62+
-------
63+
TokenData | None
64+
TokenData instance if the token is valid, None otherwise.
65+
"""
66+
is_blacklisted = await crud_token_blacklist.exists(db, token=token)
67+
if is_blacklisted:
68+
return None
69+
70+
try:
71+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
72+
username_or_email: str = payload.get("sub")
73+
if username_or_email is None:
74+
return None
75+
return TokenData(username_or_email=username_or_email)
76+
77+
except JWTError:
78+
return None
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from app.crud.crud_base import CRUDBase
2+
from app.models.token_blacklist import TokenBlacklist
3+
from app.schemas.token_blacklist import TokenBlacklistCreate, TokenBlacklistUpdate
4+
5+
CRUDTokenBlacklist = CRUDBase[TokenBlacklist, TokenBlacklistCreate, TokenBlacklistUpdate, TokenBlacklistUpdate, None]
6+
crud_token_blacklist = CRUDTokenBlacklist(TokenBlacklist)

src/app/crud/helper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from app.core.database import Base
66

7-
def _extract_matching_columns_from_schema(model: Type[Base], schema: Union[Type[BaseModel], List, None]) -> List[Any]:
7+
def _extract_matching_columns_from_schema(model: Type[Base], schema: Union[Type[BaseModel], list, None]) -> List[Any]:
88
"""
99
Retrieves a list of ORM column objects from a SQLAlchemy model that match the field names in a given Pydantic schema.
1010
@@ -46,7 +46,7 @@ def _extract_matching_columns_from_kwargs(model: Type[Base], kwargs: dict) -> Li
4646
return column_list
4747

4848

49-
def _extract_matching_columns_from_column_names(model: Type[Base], column_names: List) -> List[Any]:
49+
def _extract_matching_columns_from_column_names(model: Type[Base], column_names: list) -> List[Any]:
5050
column_list = []
5151
for column_name in column_names:
5252
if hasattr(model, column_name):

0 commit comments

Comments
 (0)