Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion {{cookiecutter.project_slug}}/backend/app/.python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.9.4
3.11.0
1 change: 1 addition & 0 deletions {{cookiecutter.project_slug}}/backend/app/__version__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__="0.1.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Token remove to invalidate

Revision ID: fb120f8fc198
Revises: 8188d671489a
Create Date: 2023-07-25 11:39:26.423122

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "fb120f8fc198"
down_revision = "8188d671489a"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("token", "authenticates_id",
existing_type=sa.UUID(),
nullable=False)
op.drop_column("token", "is_valid")
op.alter_column("user", "created",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "modified",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "email_validated",
existing_type=sa.BOOLEAN(),
nullable=False)
op.alter_column("user", "is_active",
existing_type=sa.BOOLEAN(),
nullable=False)
op.alter_column("user", "is_superuser",
existing_type=sa.BOOLEAN(),
nullable=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("user", "is_superuser",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "is_active",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "email_validated",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "modified",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "created",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.add_column("token", sa.Column("is_valid", sa.BOOLEAN(), autoincrement=False, nullable=True))
op.alter_column("token", "authenticates_id",
existing_type=sa.UUID(),
nullable=True)
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@
login,
users,
proxy,
services,
)

api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"])
api_router.include_router(services.router, prefix="/service", tags=["service"])
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any, Union, Dict
from pydantic import EmailStr

from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
Expand Down Expand Up @@ -34,7 +33,7 @@


@router.post("/magic/{email}", response_model=schemas.WebToken)
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: EmailStr) -> Any:
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: str) -> Any:
"""
First step of a 'magic link' login. Check if the user exists and generate a magic link. Generates two short-duration
jwt tokens, one for validation, one for email. Creates user if not exist.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,13 @@ def read_user(
def read_all_users(
*,
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
page: int = 0,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve all current users.
"""
return crud.user.get_multi(db=db, skip=skip, limit=limit)
return crud.user.get_multi(db=db, page=page)


@router.post("/new-totp", response_model=schemas.NewTOTP)
Expand Down
4 changes: 2 additions & 2 deletions {{cookiecutter.project_slug}}/backend/app/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ def get_refresh_user(db: Session = Depends(get_db), token: str = Depends(reusabl
raise HTTPException(status_code=400, detail="Inactive user")
# Check and revoke this refresh token
token_obj = crud.token.get(token=token, user=user)
if not token_obj or not token_obj.is_valid:
if not token_obj:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
crud.token.cancel_refresh_token(db, db_obj=token_obj)
crud.token.remove(db, db_obj=token_obj)
return user


Expand Down
33 changes: 33 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/api/sockets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from __future__ import annotations
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
from websockets.exceptions import ConnectionClosedError


async def send_response(*, websocket: WebSocket, response: dict):
try:
await websocket.send_json(response)
return True
except (WebSocketDisconnect, ConnectionClosedError):
return False


async def receive_request(*, websocket: WebSocket) -> dict:
try:
return await websocket.receive_json()
except (WebSocketDisconnect, ConnectionClosedError):
return {}


def sanitize_data_request(data: any) -> any:
# Putting here for want of a better place
if isinstance(data, (list, tuple, set)):
return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool))
elif isinstance(data, dict):
return type(data)(
(sanitize_data_request(k), sanitize_data_request(v))
for k, v in data.items()
if k and v or isinstance(v, bool)
)
else:
return data
6 changes: 6 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
return None
return v

# GENERAL SETTINGS

MULTI_MAX: int = 20

# COMPONENT SETTINGS

POSTGRES_SERVER: str
POSTGRES_USER: str
POSTGRES_PASSWORD: str
Expand Down
12 changes: 8 additions & 4 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy.orm import Session

from app.db.base_class import Base
from app.core.config import settings

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

def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
def get_multi(self, db: Session, *, page: int = 0, page_break: bool = False) -> list[ModelType]:
db_objs = db.query(self.model)
if not page_break:
if page > 0:
db_objs = db_objs.offset(page * settings.MULTI_MAX)
db_objs = db_objs.limit(settings.MULTI_MAX)
return db_objs.all()

def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
Expand Down
50 changes: 19 additions & 31 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/crud_token.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,35 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import List
from sqlalchemy import and_

from app.crud.base import CRUDBase
from app.models import User, Token
from app.schemas import RefreshTokenCreate, RefreshTokenUpdate
from app.core.config import settings


class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]):
# Everything is user-dependent
def create(self, db: Session, *, obj_in: str, user_obj: User) -> User:
def create(self, db: Session, *, obj_in: str, user_obj: User) -> Token:
db_obj = db.query(self.model).filter(self.model.token == obj_in).first()
if db_obj and db_obj.authenticates == user_obj:
# In case the token was invalidated, then recreated with the same token key
setattr(db_obj, "is_valid", True)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
if db_obj and db_obj.authenticates != user_obj:
raise ValueError(f"Token mismatch between key and user.")
db_obj = Token(token=obj_in)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
user_obj.refresh_tokens.append(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

def cancel_refresh_token(self, db: Session, *, db_obj: Token) -> Token:
setattr(db_obj, "is_valid", False)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
raise ValueError("Token mismatch between key and user.")
obj_in = RefreshTokenCreate(**{"token": obj_in, "authenticates_id": user_obj.id})
return super().create(db=db, obj_in=obj_in)

def get(self, *, user: User, token: str) -> Token:
return user.refresh_tokens.filter(and_(self.model.token == token, self.model.is_valid == True)).first()

def get_multi(self, *, user: User, skip: int = 0, limit: int = 100) -> List[Token]:
return user.refresh_tokens.filter(self.model.is_valid == True).offset(skip).limit(limit).all()

return user.refresh_tokens.filter(self.model.token == token).first()

def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]:
db_objs = user.refresh_tokens
if not page_break:
if page > 0:
db_objs = db_objs.offset(page * settings.MULTI_MAX)
db_objs = db_objs.limit(settings.MULTI_MAX)
return db_objs.all()

def remove(self, db: Session, *, db_obj: Token) -> None:
db.delete(db_obj)
db.commit()
return None

token = CRUDToken(Token)
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
def create(self, db: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
)
if obj_in.password:
db_obj.hashed_password = get_password_hash(obj_in.password)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
Expand Down Expand Up @@ -77,6 +76,11 @@ def toggle_user_state(self, db: Session, *, obj_in: Union[UserUpdate, Dict[str,
return None
return self.update(db=db, db_obj=db_obj, obj_in=obj_in)

def has_password(self, user: User) -> bool:
if user.hashed_password:
return True
return False

def is_active(self, user: User) -> bool:
return user.is_active

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,5 @@

class Token(Base):
token: Mapped[str] = mapped_column(primary_key=True, index=True)
is_valid: Mapped[bool] = mapped_column(default=True)
authenticates_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("user.id"))
authenticates: Mapped["User"] = relationship(back_populates="refresh_tokens")
17 changes: 13 additions & 4 deletions {{cookiecutter.project_slug}}/backend/app/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@
class User(Base):
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
modified: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now(), nullable=False)
full_name: Mapped[str] = mapped_column(index=True)
modified: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
server_onupdate=func.now(),
nullable=False,
)
# METADATA
full_name: Mapped[str] = mapped_column(index=True, nullable=True)
email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
hashed_password: Mapped[Optional[str]] = mapped_column(nullable=True)
# AUTHENTICATION AND PERSISTENCE
totp_secret: Mapped[Optional[str]] = mapped_column(nullable=True)
totp_counter: Mapped[Optional[str]] = mapped_column(nullable=True)
totp_counter: Mapped[Optional[int]] = mapped_column(nullable=True)
email_validated: Mapped[bool] = mapped_column(default=False)
is_active: Mapped[bool] = mapped_column(default=True)
is_superuser: Mapped[bool] = mapped_column(default=False)
refresh_tokens: Mapped[list["Token"]] = relationship(back_populates="authenticates", lazy="dynamic")
refresh_tokens: Mapped[list["Token"]] = relationship(
foreign_keys="[Token.authenticates_id]", back_populates="authenticates", lazy="dynamic"
)
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel
from uuid import UUID


class RefreshTokenBase(BaseModel):
token: str
is_valid: bool = True
authenticates_id: Optional[UUID] = None


class RefreshTokenCreate(RefreshTokenBase):
pass
authenticates_id: UUID


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


class RefreshToken(RefreshTokenUpdate):
Expand Down
Loading