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
5 changes: 5 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ class Settings:
llm_max_retries: int = _int_env("LLM_MAX_RETRIES", 3)
llm_retry_backoff: float = _float_env("LLM_RETRY_BACKOFF", 1.0)

# ── Password Reset ──────────────────────────────────────────
reset_token_expire_minutes: int = _int_env("RESET_TOKEN_EXPIRE_MINUTES", 30)
reset_rate_limit_requests: int = _int_env("RESET_RATE_LIMIT_REQUESTS", 3)
reset_rate_limit_window_seconds: int = _int_env("RESET_RATE_LIMIT_WINDOW_SECONDS", 300)

# ── Email / Digest ──────────────────────────────────────────
smtp_host: str = os.getenv("SMTP_HOST", "")
smtp_port: int = _int_env("SMTP_PORT", 587)
Expand Down
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ async def add_cache_header(request: Request, call_next):


# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(auth.router)
app.include_router(explanation.router, prefix="/explanation", tags=["Explanation"])
app.include_router(debugging.router, prefix="/debugging", tags=["Debugging"])
app.include_router(suggestions.router, prefix="/suggestions", tags=["Suggestions"])
Expand Down
15 changes: 15 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ class DigestSubscription(Base):
last_sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)


class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
token: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC)
)

user = relationship("User", backref="reset_tokens")


class SharedSnippet(Base):
__tablename__ = "shares"

Expand Down
98 changes: 95 additions & 3 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException, status
import os
import secrets
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from time import time

from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session

from ..database import get_db
from ..models import User
from ..schemas import AuthResponse, LoginRequest, SignupRequest, UserProfileResponse
from ..models import PasswordResetToken, User
from ..schemas import (
AuthResponse,
ForgotPasswordRequest,
LoginRequest,
PasswordResetResponse,
ResetPasswordRequest,
SignupRequest,
UserProfileResponse,
)
from ..security import (
create_access_token,
get_current_user,
hash_password,
verify_password,
)
from ..config import settings

router = APIRouter(prefix="/auth", tags=["Auth"])

_forgot_rate: dict[str, list[float]] = defaultdict(list)


def _check_forgot_rate_limit(email: str) -> None:
now = time()
window = settings.reset_rate_limit_window_seconds
limit = settings.reset_rate_limit_requests
key = email.lower().strip()
_forgot_rate[key] = [
t for t in _forgot_rate[key] if now - t < window
]
if len(_forgot_rate[key]) >= limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many password reset requests. Try again later.",
)
_forgot_rate[key].append(now)


@router.post("/signup", response_model=AuthResponse)
def signup(payload: SignupRequest, db: Session = Depends(get_db)):
Expand Down Expand Up @@ -54,3 +87,62 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
@router.get("/me", response_model=UserProfileResponse)
def me(current_user: User = Depends(get_current_user)):
return UserProfileResponse(user_id=current_user.id, email=current_user.email)


@router.post("/forgot-password", response_model=PasswordResetResponse)
def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)):
email = payload.email.lower().strip()
_check_forgot_rate_limit(email)

user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None:
return PasswordResetResponse(
message="If that email exists, a reset link has been sent."
)

token = secrets.token_urlsafe(64)
expires_at = datetime.now(UTC) + timedelta(minutes=settings.reset_token_expire_minutes)

reset = PasswordResetToken(
user_id=user.id,
token=token,
expires_at=expires_at,
)
db.add(reset)
db.commit()

return PasswordResetResponse(
message="If that email exists, a reset link has been sent."
)


@router.post("/reset-password", response_model=PasswordResetResponse)
def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)):
now = datetime.now(UTC)

reset = db.execute(
select(PasswordResetToken).where(
PasswordResetToken.token == payload.token.strip(),
PasswordResetToken.used_at.is_(None),
PasswordResetToken.expires_at > now,
)
).scalar_one_or_none()

if reset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)

user = db.get(User, reset.user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)

user.password_hash = hash_password(payload.new_password)
reset.used_at = now
db.commit()

return PasswordResetResponse(message="Password has been reset successfully.")
59 changes: 59 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations
import json
from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field, field_validator, model_validator
Expand Down Expand Up @@ -222,6 +223,64 @@ def sanitize_result_json_field(cls, v: str) -> str:
class HistoryRecord(BaseModel):
id: int
action: str


# ── Auth / Password Reset ────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: str
password: str


class SignupRequest(BaseModel):
email: str
password: str


class AuthResponse(BaseModel):
access_token: str
user_id: int
email: str


class UserProfileResponse(BaseModel):
user_id: int
email: str


class ForgotPasswordRequest(BaseModel):
email: str

@field_validator("email")
@classmethod
def email_must_be_valid(cls, v: str) -> str:
v = v.strip().lower()
if "@" not in v or "." not in v.split("@")[-1]:
raise ValueError("Invalid email address")
if len(v) > 320:
raise ValueError("Email too long")
return v


class ResetPasswordRequest(BaseModel):
token: str
new_password: str

@field_validator("new_password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if len(v) > 128:
raise ValueError("Password too long")
return v


class PasswordResetResponse(BaseModel):
message: str


# ── Share ─────────────────────────────────────────────────────────────────────
class ShareCreateRequest(BaseModel):
code: str
result_json: str
created_at: str
Expand Down