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
12 changes: 11 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,14 @@ marimo/_lsp/
__marimo__/

# Streamlit
.streamlit/secrets.toml
.streamlit/secrets.toml



# ReadMe Specific Gitignores
# --------------------------



# database files
*.db
2 changes: 1 addition & 1 deletion backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/api.py
# app/api/v1/api.py
# Primary API router for V1. This file can be considered the
# "main" file for the API

Expand Down
39 changes: 24 additions & 15 deletions backend/app/api/v1/auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth/auth.py
# app/api/v1/auth/auth.py
# Handles authentication logic

from .config import *
Expand All @@ -15,15 +15,27 @@
from pwdlib import PasswordHash
from typing import Annotated

from sqlmodel import select

from ..db import SessionDep
from ..models import UserDB


# Database interaction
# --------------------


def get_user(db, username: str):
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
statement = select(UserDB).where(UserDB.username == username)
user = db.exec(statement).first()
if not user:
return None
return UserInDB(
id=user.id,
username=user.username,
created_at=user.created_at,
password_hash=user.password_hash,
)


# Password hashing / verification
Expand All @@ -38,15 +50,11 @@ def get_password_hash(password):
return password_hash.hash(password)


DUMMY_HASH = password_hash.hash("dummypassword")


def authenticate_user(fake_db, username: str, password: str):
user = get_user(fake_db, username)
def authenticate_user(session: SessionDep, username: str, password: str):
user = get_user(session, username)
if not user:
verify_password(password, DUMMY_HASH)
return False
if not verify_password(password, user.hashed_password):
if not verify_password(password, user.password_hash):
return False
return user

Expand All @@ -70,7 +78,10 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
# --------------


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: SessionDep,
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
Expand All @@ -84,7 +95,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
user = get_user(session, username=token_data.username)
if user is None:
raise credentials_exception
return user
Expand All @@ -93,6 +104,4 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
46 changes: 31 additions & 15 deletions backend/app/api/v1/auth/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth/env.py
# app/api/v1/auth/env.py
# Manages authentication-specific env variables & constants

import os
from datetime import datetime

from dotenv import load_dotenv

Expand All @@ -29,35 +30,50 @@

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

fake_users_db = {
"lachlanharris": {
"username": "lachlanharris",
"full_name": "Lachlan Harris",
"email": "contact@lachlanharris.dev",
"hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$wagCPXjifgvUFBzq4hqe3w$CYaIb8sB+wtD+Vu/P4uod1+Qof8h+1g7bbDlBID48Rc", # "secret"
"disabled": False,
}
}

# Types
# -----


class Token(BaseModel):
"""
Represents a JWT access token response
"""

access_token: str
token_type: str


class TokenData(BaseModel):
"""
Represents the data contained within a JWT access token
"""

username: str | None = None


class User(BaseModel):
"""
Represents a user in the system, excluding sensitive information
for the purpose of API responses (i.e. /api/v1/auth/me)
"""

id: int
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
created_at: datetime | None = None


class UserInDB(User):
hashed_password: str
"""
Represents a user in the database, including sensitive information
"""

password_hash: str


class UserCreate(BaseModel):
"""
Represents the data required to create a new user account
"""

username: str
password: str
47 changes: 42 additions & 5 deletions backend/app/api/v1/auth/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/auth.py
# app/api/v1/auth.py
# JWT Authentication API endpoints according to the V1 specification

from .config import *
Expand All @@ -14,6 +14,11 @@
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Annotated

from sqlmodel import select

from ..db import SessionDep
from ..models import UserDB

router = APIRouter()


Expand All @@ -24,6 +29,7 @@
@router.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: SessionDep,
) -> Token:
"""
Request a JWT access token by providing a username and password
Expand All @@ -39,7 +45,7 @@ async def login_for_access_token(
Errors:
401 Unauthorized: If the username or password is incorrect
"""
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand All @@ -53,6 +59,38 @@ async def login_for_access_token(
return Token(access_token=access_token, token_type="bearer")


@router.post("/signup", response_model=User)
def signup(user: UserCreate, session: SessionDep) -> User:
"""Register a new user.

Requires:
username: string
password: string

Response:
id: integer
username: string
created_at: timestamp

Errors:
409 Conflict: If the username already exists
"""

existing = session.exec(
select(UserDB).where(UserDB.username == user.username)
).first()
if existing:
raise HTTPException(status_code=409, detail="Username already exists")

db_user = UserDB(
username=user.username, password_hash=get_password_hash(user.password)
)
session.add(db_user)
session.commit()
session.refresh(db_user)
return User(id=db_user.id, username=db_user.username, created_at=db_user.created_at)


# Utilities
# ---------

Expand All @@ -67,9 +105,8 @@ async def read_users_me(
Authorization: true

Response:
id: integer
username: string
email: string
full_name: string
disabled: boolean
created_at: timestamp
"""
return current_user
47 changes: 42 additions & 5 deletions backend/app/api/v1/db.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
from sqlmodel import Session, create_engine, select
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# app/api/v1/db/utils.py
# Database utility functions for the V1 specification

from typing import Annotated

from fastapi import Depends
from sqlalchemy.engine import Engine
from sqlmodel import SQLModel, Session, create_engine, select

from .settings import settings


def _get_connect_args(database_url: str) -> dict:
# from fastapi sql tutorial; required for SQLite across threads.
if database_url.startswith("sqlite"):
return {"check_same_thread": False}
return {}


engine: Engine = create_engine(
settings.database_url,
connect_args=_get_connect_args(settings.database_url),
pool_pre_ping=True,
)


def create_db_and_tables() -> None:
# import models to ensure registration with SQLModel
# the 'noqa' tag fixes the linter whining about the unused import
from . import models # noqa: F401

SQLModel.metadata.create_all(engine)


def get_engine() -> Engine:
return create_engine(
settings.database_url,
pool_pre_ping=True,
)
return engine


def get_session():
with Session(engine) as session:
yield session


# SessionDep is a dependency for injecting a database session into API endpoints
SessionDep = Annotated[Session, Depends(get_session)]


def ping_db(engine: Engine) -> None:
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/v1/health.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
# This project is licensed under Apache 2.0
#
# src/api/v1/health.py
# app/api/v1/health.py
# Health check API endpoints according to the V1 specification

from fastapi import APIRouter, Depends, HTTPException
Expand Down
Loading
Loading