Skip to content

Commit 9ae8786

Browse files
committed
Changed token invalidation to removal
- Token invalid settings seems somewhat pointless ... just remove it entirely to avoid risk of weird conflicts - Simplified crud paging with a site-level setting ... risk of some sort of DoS attack by having a way to bypass row-fetch limits - Placeholder for a sockets-based API
1 parent 85307cd commit 9ae8786

File tree

13 files changed

+166
-55
lines changed

13 files changed

+166
-55
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Token remove to invalidate
2+
3+
Revision ID: fb120f8fc198
4+
Revises: 8188d671489a
5+
Create Date: 2023-07-25 11:39:26.423122
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "fb120f8fc198"
14+
down_revision = "8188d671489a"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.alter_column("token", "authenticates_id",
22+
existing_type=sa.UUID(),
23+
nullable=False)
24+
op.drop_column("token", "is_valid")
25+
op.alter_column("user", "created",
26+
existing_type=postgresql.TIMESTAMP(),
27+
type_=sa.DateTime(timezone=True),
28+
existing_nullable=False,
29+
existing_server_default=sa.text("now()"))
30+
op.alter_column("user", "modified",
31+
existing_type=postgresql.TIMESTAMP(),
32+
type_=sa.DateTime(timezone=True),
33+
existing_nullable=False,
34+
existing_server_default=sa.text("now()"))
35+
op.alter_column("user", "email_validated",
36+
existing_type=sa.BOOLEAN(),
37+
nullable=False)
38+
op.alter_column("user", "is_active",
39+
existing_type=sa.BOOLEAN(),
40+
nullable=False)
41+
op.alter_column("user", "is_superuser",
42+
existing_type=sa.BOOLEAN(),
43+
nullable=False)
44+
# ### end Alembic commands ###
45+
46+
47+
def downgrade():
48+
# ### commands auto generated by Alembic - please adjust! ###
49+
op.alter_column("user", "is_superuser",
50+
existing_type=sa.BOOLEAN(),
51+
nullable=True)
52+
op.alter_column("user", "is_active",
53+
existing_type=sa.BOOLEAN(),
54+
nullable=True)
55+
op.alter_column("user", "email_validated",
56+
existing_type=sa.BOOLEAN(),
57+
nullable=True)
58+
op.alter_column("user", "modified",
59+
existing_type=sa.DateTime(timezone=True),
60+
type_=postgresql.TIMESTAMP(),
61+
existing_nullable=False,
62+
existing_server_default=sa.text("now()"))
63+
op.alter_column("user", "created",
64+
existing_type=sa.DateTime(timezone=True),
65+
type_=postgresql.TIMESTAMP(),
66+
existing_nullable=False,
67+
existing_server_default=sa.text("now()"))
68+
op.add_column("token", sa.Column("is_valid", sa.BOOLEAN(), autoincrement=False, nullable=True))
69+
op.alter_column("token", "authenticates_id",
70+
existing_type=sa.UUID(),
71+
nullable=True)
72+
# ### end Alembic commands ###

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
login,
55
users,
66
proxy,
7-
services,
87
)
98

109
api_router = APIRouter()
1110
api_router.include_router(login.router, prefix="/login", tags=["login"])
1211
api_router.include_router(users.router, prefix="/users", tags=["users"])
1312
api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"])
14-
api_router.include_router(services.router, prefix="/service", tags=["service"])

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from typing import Any, Union, Dict
2-
from pydantic import EmailStr
32

43
from fastapi import APIRouter, Body, Depends, HTTPException
54
from fastapi.security import OAuth2PasswordRequestForm
@@ -34,7 +33,7 @@
3433

3534

3635
@router.post("/magic/{email}", response_model=schemas.WebToken)
37-
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: EmailStr) -> Any:
36+
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: str) -> Any:
3837
"""
3938
First step of a 'magic link' login. Check if the user exists and generate a magic link. Generates two short-duration
4039
jwt tokens, one for validation, one for email. Creates user if not exist.

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,13 @@ def read_user(
8686
def read_all_users(
8787
*,
8888
db: Session = Depends(deps.get_db),
89-
skip: int = 0,
90-
limit: int = 100,
89+
page: int = 0,
9190
current_user: models.User = Depends(deps.get_current_active_superuser),
9291
) -> Any:
9392
"""
9493
Retrieve all current users.
9594
"""
96-
return crud.user.get_multi(db=db, skip=skip, limit=limit)
95+
return crud.user.get_multi(db=db, page=page)
9796

9897

9998
@router.post("/new-totp", response_model=schemas.NewTOTP)

{{cookiecutter.project_slug}}/backend/app/app/api/deps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,12 @@ def get_refresh_user(db: Session = Depends(get_db), token: str = Depends(reusabl
8888
raise HTTPException(status_code=400, detail="Inactive user")
8989
# Check and revoke this refresh token
9090
token_obj = crud.token.get(token=token, user=user)
91-
if not token_obj or not token_obj.is_valid:
91+
if not token_obj:
9292
raise HTTPException(
9393
status_code=status.HTTP_403_FORBIDDEN,
9494
detail="Could not validate credentials",
9595
)
96-
crud.token.cancel_refresh_token(db, db_obj=token_obj)
96+
crud.token.remove(db, db_obj=token_obj)
9797
return user
9898

9999

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
from fastapi import WebSocket
3+
from starlette.websockets import WebSocketDisconnect
4+
from websockets.exceptions import ConnectionClosedError
5+
6+
7+
async def send_response(*, websocket: WebSocket, response: dict):
8+
try:
9+
await websocket.send_json(response)
10+
return True
11+
except (WebSocketDisconnect, ConnectionClosedError):
12+
return False
13+
14+
15+
async def receive_request(*, websocket: WebSocket) -> dict:
16+
try:
17+
return await websocket.receive_json()
18+
except (WebSocketDisconnect, ConnectionClosedError):
19+
return {}
20+
21+
22+
def sanitize_data_request(data: any) -> any:
23+
# Putting here for want of a better place
24+
if isinstance(data, (list, tuple, set)):
25+
return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool))
26+
elif isinstance(data, dict):
27+
return type(data)(
28+
(sanitize_data_request(k), sanitize_data_request(v))
29+
for k, v in data.items()
30+
if k and v or isinstance(v, bool)
31+
)
32+
else:
33+
return data

{{cookiecutter.project_slug}}/backend/app/app/core/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
3838
return None
3939
return v
4040

41+
# GENERAL SETTINGS
42+
43+
MULTI_MAX: int = 20
44+
45+
# COMPONENT SETTINGS
46+
4147
POSTGRES_SERVER: str
4248
POSTGRES_USER: str
4349
POSTGRES_PASSWORD: str

{{cookiecutter.project_slug}}/backend/app/app/crud/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.orm import Session
66

77
from app.db.base_class import Base
8+
from app.core.config import settings
89

910
ModelType = TypeVar("ModelType", bound=Base)
1011
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
@@ -26,10 +27,13 @@ def __init__(self, model: Type[ModelType]):
2627
def get(self, db: Session, id: Any) -> Optional[ModelType]:
2728
return db.query(self.model).filter(self.model.id == id).first()
2829

29-
def get_multi(
30-
self, db: Session, *, skip: int = 0, limit: int = 100
31-
) -> List[ModelType]:
32-
return db.query(self.model).offset(skip).limit(limit).all()
30+
def get_multi(self, db: Session, *, page: int = 0, page_break: bool = False) -> list[ModelType]:
31+
db_objs = db.query(self.model)
32+
if not page_break:
33+
if page > 0:
34+
db_objs = db_objs.offset(page * settings.MULTI_MAX)
35+
db_objs = db_objs.limit(settings.MULTI_MAX)
36+
return db_objs.all()
3337

3438
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
3539
obj_in_data = jsonable_encoder(obj_in)
Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,35 @@
11
from __future__ import annotations
22
from sqlalchemy.orm import Session
3-
from typing import List
4-
from sqlalchemy import and_
53

64
from app.crud.base import CRUDBase
75
from app.models import User, Token
86
from app.schemas import RefreshTokenCreate, RefreshTokenUpdate
7+
from app.core.config import settings
98

109

1110
class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]):
1211
# Everything is user-dependent
13-
def create(self, db: Session, *, obj_in: str, user_obj: User) -> User:
12+
def create(self, db: Session, *, obj_in: str, user_obj: User) -> Token:
1413
db_obj = db.query(self.model).filter(self.model.token == obj_in).first()
15-
if db_obj and db_obj.authenticates == user_obj:
16-
# In case the token was invalidated, then recreated with the same token key
17-
setattr(db_obj, "is_valid", True)
18-
db.add(db_obj)
19-
db.commit()
20-
db.refresh(db_obj)
21-
return db_obj
2214
if db_obj and db_obj.authenticates != user_obj:
23-
raise ValueError(f"Token mismatch between key and user.")
24-
db_obj = Token(token=obj_in)
25-
db.add(db_obj)
26-
db.commit()
27-
db.refresh(db_obj)
28-
user_obj.refresh_tokens.append(db_obj)
29-
db.commit()
30-
db.refresh(db_obj)
31-
return db_obj
32-
33-
def cancel_refresh_token(self, db: Session, *, db_obj: Token) -> Token:
34-
setattr(db_obj, "is_valid", False)
35-
db.add(db_obj)
36-
db.commit()
37-
db.refresh(db_obj)
38-
return db_obj
15+
raise ValueError("Token mismatch between key and user.")
16+
obj_in = RefreshTokenCreate(**{"token": obj_in, "authenticates_id": user_obj.id})
17+
return super().create(db=db, obj_in=obj_in)
3918

4019
def get(self, *, user: User, token: str) -> Token:
41-
return user.refresh_tokens.filter(and_(self.model.token == token, self.model.is_valid == True)).first()
42-
43-
def get_multi(self, *, user: User, skip: int = 0, limit: int = 100) -> List[Token]:
44-
return user.refresh_tokens.filter(self.model.is_valid == True).offset(skip).limit(limit).all()
45-
20+
return user.refresh_tokens.filter(self.model.token == token).first()
21+
22+
def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]:
23+
db_objs = user.refresh_tokens
24+
if not page_break:
25+
if page > 0:
26+
db_objs = db_objs.offset(page * settings.MULTI_MAX)
27+
db_objs = db_objs.limit(settings.MULTI_MAX)
28+
return db_objs.all()
29+
30+
def remove(self, db: Session, *, db_obj: Token) -> None:
31+
db.delete(db_obj)
32+
db.commit()
33+
return None
4634

4735
token = CRUDToken(Token)

{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
1616
def create(self, db: Session, *, obj_in: UserCreate) -> User:
1717
db_obj = User(
1818
email=obj_in.email,
19+
hashed_password=get_password_hash(obj_in.password),
1920
full_name=obj_in.full_name,
2021
is_superuser=obj_in.is_superuser,
2122
)
22-
if obj_in.password:
23-
db_obj.hashed_password = get_password_hash(obj_in.password)
2423
db.add(db_obj)
2524
db.commit()
2625
db.refresh(db_obj)
@@ -77,6 +76,11 @@ def toggle_user_state(self, db: Session, *, obj_in: Union[UserUpdate, Dict[str,
7776
return None
7877
return self.update(db=db, db_obj=db_obj, obj_in=obj_in)
7978

79+
def has_password(self, user: User) -> bool:
80+
if user.hashed_password:
81+
return True
82+
return False
83+
8084
def is_active(self, user: User) -> bool:
8185
return user.is_active
8286

{{cookiecutter.project_slug}}/backend/app/app/models/token.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,5 @@
1212

1313
class Token(Base):
1414
token: Mapped[str] = mapped_column(primary_key=True, index=True)
15-
is_valid: Mapped[bool] = mapped_column(default=True)
1615
authenticates_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("user.id"))
1716
authenticates: Mapped["User"] = relationship(back_populates="refresh_tokens")

{{cookiecutter.project_slug}}/backend/app/app/models/user.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,22 @@
1717
class User(Base):
1818
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4)
1919
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
20-
modified: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now(), nullable=False)
21-
full_name: Mapped[str] = mapped_column(index=True)
20+
modified: Mapped[datetime] = mapped_column(
21+
DateTime(timezone=True),
22+
server_default=func.now(),
23+
server_onupdate=func.now(),
24+
nullable=False,
25+
)
26+
# METADATA
27+
full_name: Mapped[str] = mapped_column(index=True, nullable=True)
2228
email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
2329
hashed_password: Mapped[Optional[str]] = mapped_column(nullable=True)
30+
# AUTHENTICATION AND PERSISTENCE
2431
totp_secret: Mapped[Optional[str]] = mapped_column(nullable=True)
25-
totp_counter: Mapped[Optional[str]] = mapped_column(nullable=True)
32+
totp_counter: Mapped[Optional[int]] = mapped_column(nullable=True)
2633
email_validated: Mapped[bool] = mapped_column(default=False)
2734
is_active: Mapped[bool] = mapped_column(default=True)
2835
is_superuser: Mapped[bool] = mapped_column(default=False)
29-
refresh_tokens: Mapped[list["Token"]] = relationship(back_populates="authenticates", lazy="dynamic")
36+
refresh_tokens: Mapped[list["Token"]] = relationship(
37+
foreign_keys="[Token.authenticates_id]", back_populates="authenticates", lazy="dynamic"
38+
)

{{cookiecutter.project_slug}}/backend/app/app/schemas/token.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
from typing import Optional
2-
from pydantic import BaseModel, Field
2+
from pydantic import BaseModel
33
from uuid import UUID
44

55

66
class RefreshTokenBase(BaseModel):
77
token: str
8-
is_valid: bool = True
8+
authenticates_id: Optional[UUID] = None
99

1010

1111
class RefreshTokenCreate(RefreshTokenBase):
12-
pass
12+
authenticates_id: UUID
1313

1414

1515
class RefreshTokenUpdate(RefreshTokenBase):
16-
is_valid: bool = Field(..., description="Deliberately disable a refresh token.")
16+
pass
1717

1818

1919
class RefreshToken(RefreshTokenUpdate):

0 commit comments

Comments
 (0)