Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ DB_KEEPALIVES_COUNT=5

# Generate a value with: openssl rand -hex 32
SECRET_KEY=replace-with-a-random-secret
ACCESS_TOKEN_EXPIRE_MINUTES=60
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30
REFRESH_TOKEN_IDLE_DAYS=7
REFRESH_COOKIE_NAME=htv_refresh
REFRESH_COOKIE_SECURE=false
REFRESH_COOKIE_SAMESITE=lax
ACTIVATION_TOKEN_EXPIRE_MINUTES=60
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=15
PASSWORD_RESET_COOLDOWN_MINUTES=15
Expand Down
7 changes: 6 additions & 1 deletion .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ POSTGRES_DB=postgres
DATABASE_URL=postgresql://postgres:replace-with-the-same-password@db:5432/postgres

SECRET_KEY=replace-with-output-of-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRE_MINUTES=60
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30
REFRESH_TOKEN_IDLE_DAYS=7
REFRESH_COOKIE_NAME=htv_refresh
REFRESH_COOKIE_SECURE=true
REFRESH_COOKIE_SAMESITE=lax
ACTIVATION_TOKEN_EXPIRE_MINUTES=60
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=15
PASSWORD_RESET_COOLDOWN_MINUTES=15
Expand Down
10 changes: 9 additions & 1 deletion alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
from sqlmodel import SQLModel

from alembic import context
from app.models import bulk_email, food_tracking, forms, judging, meal, user # noqa: F401
from app.models import ( # noqa: F401
bulk_email,
food_tracking,
forms,
judging,
meal,
refresh_session,
user,
)

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
71 changes: 71 additions & 0 deletions alembic/versions/e84b6a92c1d4_add_refresh_sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""add refresh sessions

Revision ID: e84b6a92c1d4
Revises: a31f0e8c4d12
"""

from collections.abc import Sequence

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

revision: str = "e84b6a92c1d4"
down_revision: str | None = "a31f0e8c4d12"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"account_refresh_session",
sa.Column("session_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("family_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("token_version", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("replaced_by_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(
["replaced_by_id"],
["account_refresh_session.session_id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(["user_id"], ["account_user.uid"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("session_id"),
)
op.create_index(
op.f("ix_account_refresh_session_family_id"),
"account_refresh_session",
["family_id"],
)
op.create_index(
op.f("ix_account_refresh_session_token_hash"),
"account_refresh_session",
["token_hash"],
unique=True,
)
op.create_index(
op.f("ix_account_refresh_session_user_id"),
"account_refresh_session",
["user_id"],
)


def downgrade() -> None:
op.drop_index(
op.f("ix_account_refresh_session_user_id"),
table_name="account_refresh_session",
)
op.drop_index(
op.f("ix_account_refresh_session_token_hash"),
table_name="account_refresh_session",
)
op.drop_index(
op.f("ix_account_refresh_session_family_id"),
table_name="account_refresh_session",
)
op.drop_table("account_refresh_session")
9 changes: 7 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from datetime import datetime
from functools import lru_cache
from typing import Annotated, Any
from typing import Annotated, Any, Literal

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
Expand Down Expand Up @@ -30,7 +30,12 @@ class Settings(BaseSettings):

SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
REFRESH_TOKEN_IDLE_DAYS: int = 7
REFRESH_COOKIE_NAME: str = "htv_refresh"
REFRESH_COOKIE_SECURE: bool = True
REFRESH_COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
ACTIVATION_TOKEN_EXPIRE_MINUTES: int = 60
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 15
PASSWORD_RESET_COOLDOWN_MINUTES: int = 15
Expand Down
42 changes: 42 additions & 0 deletions app/models/refresh_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime, timezone
from typing import ClassVar

from sqlmodel import Column, DateTime, Field, SQLModel, String


def utc_now() -> datetime:
return datetime.now(timezone.utc)


class RefreshSession(SQLModel, table=True):
__tablename__: ClassVar[str] = "account_refresh_session"

session_id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(
foreign_key="account_user.uid", ondelete="CASCADE", index=True
)
family_id: uuid.UUID = Field(default_factory=uuid.uuid4, index=True)
token_hash: str = Field(
sa_column=Column(String(64), nullable=False, unique=True, index=True)
)
token_version: int = Field(nullable=False)
created_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(DateTime(timezone=True), nullable=False),
)
last_used_at: datetime = Field(
default_factory=utc_now,
sa_column=Column(DateTime(timezone=True), nullable=False),
)
expires_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False)
)
revoked_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
replaced_by_id: uuid.UUID | None = Field(
default=None,
foreign_key="account_refresh_session.session_id",
ondelete="SET NULL",
)
72 changes: 69 additions & 3 deletions app/routers/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@
from typing import Annotated

import bcrypt
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi.responses import Response
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Query,
Request,
Response,
status,
)
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError

from app.config import AppConfig, SecurityConfig
from app.core.db import SessionDep
from app.core.errors import ServiceError
from app.core.orm import eager_load
from app.models.constants import (
EmailMessage,
Expand All @@ -29,6 +38,11 @@
decode_token,
scopes_for_user,
)
from app.services.refresh_sessions import (
create_refresh_session,
revoke_refresh_session,
rotate_refresh_session,
)
from app.services.email import (
send_activation_email_in_background,
send_email,
Expand All @@ -54,11 +68,34 @@ def _invalid_login() -> HTTPException:
)


def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=SecurityConfig.REFRESH_COOKIE_NAME,
value=token,
max_age=SecurityConfig.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path="/api/account/tokens",
secure=SecurityConfig.REFRESH_COOKIE_SECURE,
httponly=True,
samesite=SecurityConfig.REFRESH_COOKIE_SAMESITE,
)


def _clear_refresh_cookie(response: Response) -> None:
response.delete_cookie(
key=SecurityConfig.REFRESH_COOKIE_NAME,
path="/api/account/tokens",
secure=SecurityConfig.REFRESH_COOKIE_SECURE,
httponly=True,
samesite=SecurityConfig.REFRESH_COOKIE_SAMESITE,
)


@router.post("/sessions")
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: SessionDep,
background_tasks: BackgroundTasks,
response: Response,
) -> Token:
statement = select(AccountUser).where(AccountUser.email == form_data.username)
selected_user = session.exec(statement).first()
Expand Down Expand Up @@ -108,6 +145,9 @@ def login(
scopes = scopes_for_user(selected_user)
access_token_expires = timedelta(minutes=SecurityConfig.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_user_access_token(selected_user, scopes, access_token_expires)
_, refresh_token = create_refresh_session(session, selected_user)
session.commit()
_set_refresh_cookie(response, refresh_token)
return Token(access_token=access_token, token_type="bearer")


Expand Down Expand Up @@ -268,15 +308,41 @@ def activate(user: UserUpdate, session: SessionDep) -> bool:

@router.post("/tokens")
def refresh(
current_user: Annotated[AccountUser, Depends(get_current_user)],
request: Request,
response: Response,
session: SessionDep,
) -> Token:
refresh_token = request.cookies.get(SecurityConfig.REFRESH_COOKIE_NAME)
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token required",
)
try:
current_user, replacement_token = rotate_refresh_session(session, refresh_token)
except ServiceError as error:
_clear_refresh_cookie(response)
raise HTTPException(
status_code=error.status_code,
detail=error.detail,
headers={"Set-Cookie": response.headers["set-cookie"]},
) from error
access_token_expires = timedelta(minutes=SecurityConfig.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_user_access_token(
current_user, scopes_for_user(current_user), access_token_expires
)
_set_refresh_cookie(response, replacement_token)
return Token(access_token=access_token, token_type="bearer")


@router.delete("/tokens", status_code=status.HTTP_204_NO_CONTENT)
def logout(request: Request, response: Response, session: SessionDep) -> None:
revoke_refresh_session(
session, request.cookies.get(SecurityConfig.REFRESH_COOKIE_NAME)
)
_clear_refresh_cookie(response)


@router.get("/apple-wallet/{application_id}")
def apple_wallet(
application_id: str,
Expand Down
Loading