Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b1038f8
Remove placeholder file
Jan 21, 2026
7503e40
Implement User Authentication system via Claude
Jan 21, 2026
1dd20f2
Pin bcrypt to v4 for passlib compatibility
Jan 22, 2026
87b3a0f
Add Claude docs related to authentication
Jan 22, 2026
ee82742
Lint and add unit tests
Jan 22, 2026
31e5a6d
Update weak password check
Jan 22, 2026
ebecb7d
Fix timezone aware issue
Jan 22, 2026
de7f42f
Lint
Jan 22, 2026
826c6b9
Adding OAuth2 flow example
golharam Jan 28, 2026
1d0786c
Add documentation
golharam Jan 28, 2026
3c704a0
Update the manifest validation endpoints to use the lambda functions.…
EricSDavis Jan 28, 2026
6c107d2
Tests and lint
EricSDavis Jan 28, 2026
eaff432
Return is_superuser in UserPublic
golharam Jan 30, 2026
f1668f1
Remove user_id field
golharam Feb 2, 2026
6b6f9d1
Fix downgrade
golharam Feb 2, 2026
1ab01df
Fix changes related to removing user_id
golharam Feb 2, 2026
89f938c
Remove Passlib
golharam Feb 2, 2026
c04904d
Fix email support to use SMTP service instead of AWS SES
golharam Feb 3, 2026
c9be199
Make first user the admin/superuser
golharam Feb 3, 2026
35d79bb
Fix first user as admin
golharam Feb 3, 2026
e96d234
Fix mismatch with user.id and user.username when creating refresh tokens
golharam Feb 3, 2026
b31b068
Fix get user from token issue
golharam Feb 3, 2026
001f223
Fix issues with refreshing token
golharam Feb 3, 2026
7f70f53
Fix user registration unit test
golharam Feb 3, 2026
0713e82
Lint
golharam Feb 3, 2026
3704359
Fix unit test assert to parse new json error response format
golharam Feb 3, 2026
2a96aee
Remove deprecated .utcnow
golharam Feb 3, 2026
309976a
fix db cleanup warnings when running unit tests with sqllite
golharam Feb 3, 2026
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
3 changes: 3 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from core.config import get_settings

# Import all models here so that they are registered with SQLModel metadata
from api.auth.models import (
User, RefreshToken, PasswordResetToken, EmailVerificationToken
)
from api.files.models import File
from api.samples.models import Sample, SampleAttribute
from api.settings.models import Setting
Expand Down
113 changes: 113 additions & 0 deletions alembic/versions/b6847b89d202_add_user_auth_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Add user auth tables

Revision ID: b6847b89d202
Revises: d89d27d47634
Create Date: 2026-02-02 09:55:21.853229

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = 'b6847b89d202'
down_revision: Union[str, Sequence[str], None] = 'd89d27d47634'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.Column('failed_login_attempts', sa.Integer(), nullable=False),
sa.Column('locked_until', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('email_verification_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['username'], ['users.username'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True)
op.create_index(op.f('ix_email_verification_tokens_username'), 'email_verification_tokens', ['username'], unique=False)
op.create_table('oauth_providers',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('provider_name', sa.Enum('GOOGLE', 'GITHUB', 'MICROSOFT', name='oauthprovidername'), nullable=False),
sa.Column('provider_username', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('access_token', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
sa.Column('token_expires_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['username'], ['users.username'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_providers_provider_name'), 'oauth_providers', ['provider_name'], unique=False)
op.create_index(op.f('ix_oauth_providers_username'), 'oauth_providers', ['username'], unique=False)
op.create_table('password_reset_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['username'], ['users.username'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
op.create_index(op.f('ix_password_reset_tokens_username'), 'password_reset_tokens', ['username'], unique=False)
op.create_table('refresh_tokens',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('device_info', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True),
sa.ForeignKeyConstraint(['username'], ['users.username'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True)
op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop tables with foreign keys first (table drops automatically handle indexes and constraints)
op.drop_table('refresh_tokens')
op.drop_table('password_reset_tokens')
op.drop_table('oauth_providers')
op.drop_table('email_verification_tokens')
# Now drop the users table with its indexes explicitly
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
156 changes: 156 additions & 0 deletions api/auth/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""
Authentication dependencies for protecting endpoints
"""
from typing import Annotated
import uuid

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError

from core.deps import SessionDep
from core.security import decode_token
from api.auth.models import User

# OAuth2 scheme for token extraction
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")

# Optional OAuth2 scheme (doesn't raise error if no token)
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl="/api/v1/auth/login",
auto_error=False
)


def get_current_user(
session: SessionDep,
token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
"""
Get current authenticated user from JWT token

Args:
session: Database session
token: JWT access token

Returns:
Current User object

Raises:
HTTPException: If token is invalid or user not found
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

try:
payload = decode_token(token)
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception

# Parse the UUID from the string
user_id = uuid.UUID(user_id_str)

except (JWTError, ValueError):
raise credentials_exception

user = session.get(User, user_id)
if user is None:
raise credentials_exception

return user


def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
) -> User:
"""
Get current active user (must be active and verified)

Args:
current_user: Current user from get_current_user

Returns:
Current User object

Raises:
HTTPException: If user is inactive or unverified
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)

if not current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified"
)

return current_user


def get_current_superuser(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""
Get current superuser (must be active, verified, and superuser)

Args:
current_user: Current user from get_current_active_user

Returns:
Current User object

Raises:
HTTPException: If user is not a superuser
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)

return current_user


def optional_current_user(
session: SessionDep,
token: Annotated[str | None, Depends(oauth2_scheme_optional)]
) -> User | None:
"""
Get current user if token is provided, None otherwise

Args:
session: Database session
token: Optional JWT access token

Returns:
Current User object or None
"""
if token is None:
return None

try:
payload = decode_token(token)
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
return None

# Parse the UUID from the string
user_id = uuid.UUID(user_id_str)

return session.get(User, user_id)
except (JWTError, ValueError):
return None


# Type aliases for cleaner endpoint signatures
CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentActiveUser = Annotated[User, Depends(get_current_active_user)]
CurrentSuperuser = Annotated[User, Depends(get_current_superuser)]
OptionalUser = Annotated[User | None, Depends(optional_current_user)]
Loading