Skip to content
Merged
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: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7

# =============================================================================
# Admin Bootstrap (optional)
# =============================================================================
# If set, registering with this email auto-promotes to admin role.
# Leave empty or remove to disable auto-admin (all users register as USER).
ADMIN_EMAIL=

# =============================================================================
# CORS (must match HOST PORTS above!)
# =============================================================================
Expand Down
67 changes: 67 additions & 0 deletions backend/alembic/versions/20251224_033104_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""initial

Revision ID: 801b86be184b
Revises:
Create Date: 2025-12-24 03:31:04.712921
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = '801b86be184b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.Column('role', sa.Enum('unknown', 'user', 'admin', name='userrole'), nullable=False),
sa.Column('token_version', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('refresh_tokens',
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('family_id', sa.Uuid(), nullable=False),
sa.Column('device_id', sa.String(length=255), nullable=True),
sa.Column('device_name', sa.String(length=100), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens'))
)
op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
28 changes: 0 additions & 28 deletions backend/alembic/versions/2025_12_07_001_initial_schema.py

This file was deleted.

24 changes: 0 additions & 24 deletions backend/app/__init__.py

This file was deleted.

25 changes: 24 additions & 1 deletion backend/app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
ⒸAngelaMos | 2025
__main__.py
"""

import uvicorn

from config import settings
Expand All @@ -12,8 +13,30 @@

if __name__ == "__main__":
uvicorn.run(
"__main__:app",
"app.__main__:app",
host = settings.HOST,
port = settings.PORT,
reload = settings.RELOAD,
)
"""

⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀

"""
4 changes: 4 additions & 0 deletions backend/app/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
ⒸAngelaMos | 2025
Admin Domain
"""
4 changes: 4 additions & 0 deletions backend/app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
ⒸAngelaMos | 2025
Auth Domain
"""
5 changes: 4 additions & 1 deletion backend/app/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ async def login(
responses = {**AUTH_401}
)
async def refresh_token(
response: Response,
auth_service: AuthServiceDep,
ip: ClientIP,
refresh_token: str | None = Cookie(None),
Expand All @@ -83,10 +84,12 @@ async def refresh_token(
"""
if not refresh_token:
raise TokenError("Refresh token required")
return await auth_service.refresh_tokens(
result, new_refresh_token = await auth_service.refresh_tokens(
refresh_token,
ip_address = ip
)
set_refresh_cookie(response, new_refresh_token)
return result


@router.post(
Expand Down
17 changes: 12 additions & 5 deletions backend/app/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ async def authenticate(
raise InvalidCredentials()

if new_hash:
await UserRepository.update_password(self.session, user, new_hash)
await UserRepository.update_password(
self.session,
user,
new_hash
)

access_token = create_access_token(user.id, user.token_version)

Expand Down Expand Up @@ -115,7 +119,7 @@ async def refresh_tokens(
device_id: str | None = None,
device_name: str | None = None,
ip_address: str | None = None,
) -> TokenResponse:
) -> tuple[TokenResponse, str]:
"""
Refresh access token using refresh token

Expand Down Expand Up @@ -147,11 +151,14 @@ async def refresh_tokens(
if user is None or not user.is_active:
raise TokenError(message = "User not found or inactive")

await RefreshTokenRepository.revoke_token(self.session, stored_token)
await RefreshTokenRepository.revoke_token(
self.session,
stored_token
)

access_token = create_access_token(user.id, user.token_version)

_, new_hash, expires_at = create_refresh_token(
new_raw_token, new_hash, expires_at = create_refresh_token(
user.id, stored_token.family_id
)

Expand All @@ -166,7 +173,7 @@ async def refresh_tokens(
ip_address = ip_address,
)

return TokenResponse(access_token = access_token)
return TokenResponse(access_token = access_token), new_raw_token

async def logout(
self,
Expand Down
9 changes: 6 additions & 3 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from functools import lru_cache

from pydantic import (
EmailStr,
Field,
RedisDsn,
SecretStr,
Expand Down Expand Up @@ -80,9 +81,9 @@ class Settings(BaseSettings):

APP_NAME: str = "FastAPI Template"
APP_VERSION: str = "1.0.0"
APP_SUMMARY: str = "FastAPI Backend Template"
APP_DESCRIPTION: str = "Async first boilerplate - JWT, Asyncdb, PostgreSQL"
APP_CONTACT_NAME: str = "AngelaMos"
APP_SUMMARY: str = "Developed CarterPerez-dev"
APP_DESCRIPTION: str = "FastAPI async first boilerplate - JWT, Asyncdb, PostgreSQL"
APP_CONTACT_NAME: str = "AngelaMos LLC"
APP_CONTACT_EMAIL: str = "support@certgames.com"
APP_LICENSE_NAME: str = "MIT"
APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/fullstack-template/docs/templates/MIT"
Expand All @@ -105,6 +106,8 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30)

ADMIN_EMAIL: EmailStr | None = None

REDIS_URL: RedisDsn | None = None

CORS_ORIGINS: list[str] = [
Expand Down
2 changes: 2 additions & 0 deletions backend/app/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ def create_app() -> FastAPI:
"url": settings.APP_LICENSE_URL,
},
openapi_tags = OPENAPI_TAGS,
openapi_version = "3.1.0",
lifespan = lifespan,
root_path = "/api" if not is_production else "",
openapi_url = None if is_production else "/openapi.json",
docs_url = None if is_production else "/docs",
redoc_url = None if is_production else "/redoc",
Expand Down
4 changes: 4 additions & 0 deletions backend/app/user/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
ⒸAngelaMos | 2025
User Domain
"""
3 changes: 3 additions & 0 deletions backend/app/user/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from config import UserRole
from .User import User
from core.base_repository import BaseRepository

Expand Down Expand Up @@ -63,6 +64,7 @@ async def create_user(
email: str,
hashed_password: str,
full_name: str | None = None,
role: UserRole = UserRole.USER,
) -> User:
"""
Create a new user
Expand All @@ -71,6 +73,7 @@ async def create_user(
email = email,
hashed_password = hashed_password,
full_name = full_name,
role = role,
)
session.add(user)
await session.flush()
Expand Down
13 changes: 11 additions & 2 deletions backend/app/user/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AsyncSession,
)

from config import settings, UserRole
from core.exceptions import (
EmailAlreadyExists,
InvalidCredentials,
Expand Down Expand Up @@ -43,15 +44,22 @@ async def create_user(
"""
Register a new user
"""
if await UserRepository.email_exists(self.session, user_data.email):
if await UserRepository.email_exists(self.session,
user_data.email):
raise EmailAlreadyExists(user_data.email)

role = UserRole.USER
if settings.ADMIN_EMAIL and user_data.email.lower(
) == settings.ADMIN_EMAIL.lower():
role = UserRole.ADMIN

hashed = await hash_password(user_data.password)
user = await UserRepository.create_user(
self.session,
email = user_data.email,
hashed_password = hashed,
full_name = user_data.full_name,
role = role,
)
return UserResponse.model_validate(user)

Expand Down Expand Up @@ -154,7 +162,8 @@ async def admin_create_user(
"""
Admin creates a new user
"""
if await UserRepository.email_exists(self.session, user_data.email):
if await UserRepository.email_exists(self.session,
user_data.email):
raise EmailAlreadyExists(user_data.email)

hashed = await hash_password(user_data.password)
Expand Down
9 changes: 8 additions & 1 deletion backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
Test configuration, fixtures, and factories
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "app"))

import hashlib
import secrets
from datetime import (
Expand Down Expand Up @@ -83,7 +88,9 @@ async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
"""
Async HTTP client with DB session override
"""
from __main__ import app
from factory import create_app

app = create_app()

async def override_get_db():
yield db_session
Expand Down
Loading
Loading