RamshaAnwar commited on
Commit
148b563
·
unverified ·
1 Parent(s): d2d4409

feat(auth): password hashing, JWT, and signup (#3)

Browse files

* feat(security): add bcrypt hashing and jwt helpers

* feat(schemas): add user create and response schemas with byte validation

* feat(auth): add signup endpoint and register auth router

* test(auth): cover signup, case-insensitivity, and password limits

* chore: add missing error tests and alembic boilerplate

alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
app/main.py CHANGED
@@ -4,6 +4,7 @@ from fastapi import FastAPI, Request
4
  from fastapi.exceptions import RequestValidationError
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
6
  from app.errors import AppError, error_response
 
7
 
8
  logger = logging.getLogger("uvicorn.error")
9
 
@@ -15,7 +16,9 @@ def create_app() -> FastAPI:
15
  redoc_url=None,
16
  )
17
 
18
- # 1. Catch-all middleware (must be inside Request-ID)
 
 
19
  @app.middleware("http")
20
  async def catch_unhandled(request: Request, call_next):
21
  try:
@@ -24,7 +27,6 @@ def create_app() -> FastAPI:
24
  logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", "unknown")})
25
  return error_response(request, 500, "INTERNAL_ERROR", "Unexpected server error.")
26
 
27
- # 2. Request-ID middleware
28
  @app.middleware("http")
29
  async def add_request_id(request: Request, call_next):
30
  request.state.request_id = str(uuid.uuid4())
@@ -32,7 +34,6 @@ def create_app() -> FastAPI:
32
  response.headers["X-Request-ID"] = request.state.request_id
33
  return response
34
 
35
- # 3. Exception Handlers
36
  @app.exception_handler(AppError)
37
  async def app_error_handler(request: Request, exc: AppError):
38
  return error_response(request, exc.status_code, exc.code, exc.message, exc.details, exc.headers)
@@ -41,23 +42,14 @@ def create_app() -> FastAPI:
41
  async def validation_error_handler(request: Request, exc: RequestValidationError):
42
  details = []
43
  for error in exc.errors():
44
- # error["loc"] looks like ("body", "name")
45
  loc = error.get("loc", [])
46
  field = ".".join(str(x) for x in loc) if loc else "unknown"
47
-
48
  details.append({
49
  "field": field,
50
  "reason": error.get("type", "invalid"),
51
  "message": error.get("msg", "Invalid value")
52
  })
53
-
54
- return error_response(
55
- request,
56
- 422,
57
- "VALIDATION_ERROR",
58
- "Request validation failed.",
59
- details=details
60
- )
61
 
62
  @app.exception_handler(StarletteHTTPException)
63
  async def http_exception_handler(request: Request, exc: StarletteHTTPException):
 
4
  from fastapi.exceptions import RequestValidationError
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
6
  from app.errors import AppError, error_response
7
+ from app.routers import auth # <-- IMPORT THE ROUTER
8
 
9
  logger = logging.getLogger("uvicorn.error")
10
 
 
16
  redoc_url=None,
17
  )
18
 
19
+ # Register the router
20
+ app.include_router(auth.router, prefix="/api/v1")
21
+
22
  @app.middleware("http")
23
  async def catch_unhandled(request: Request, call_next):
24
  try:
 
27
  logger.exception("unhandled error", extra={"request_id": getattr(request.state, "request_id", "unknown")})
28
  return error_response(request, 500, "INTERNAL_ERROR", "Unexpected server error.")
29
 
 
30
  @app.middleware("http")
31
  async def add_request_id(request: Request, call_next):
32
  request.state.request_id = str(uuid.uuid4())
 
34
  response.headers["X-Request-ID"] = request.state.request_id
35
  return response
36
 
 
37
  @app.exception_handler(AppError)
38
  async def app_error_handler(request: Request, exc: AppError):
39
  return error_response(request, exc.status_code, exc.code, exc.message, exc.details, exc.headers)
 
42
  async def validation_error_handler(request: Request, exc: RequestValidationError):
43
  details = []
44
  for error in exc.errors():
 
45
  loc = error.get("loc", [])
46
  field = ".".join(str(x) for x in loc) if loc else "unknown"
 
47
  details.append({
48
  "field": field,
49
  "reason": error.get("type", "invalid"),
50
  "message": error.get("msg", "Invalid value")
51
  })
52
+ return error_response(request, 422, "VALIDATION_ERROR", "Request validation failed.", details=details)
 
 
 
 
 
 
 
53
 
54
  @app.exception_handler(StarletteHTTPException)
55
  async def http_exception_handler(request: Request, exc: StarletteHTTPException):
app/models.py CHANGED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+ from sqlalchemy import Column, String, Boolean, Integer, DateTime, ForeignKey, UniqueConstraint, LargeBinary
4
+ from sqlalchemy.dialects.postgresql import UUID, JSONB
5
+ from pgvector.sqlalchemy import Vector
6
+ from app.db import Base
7
+
8
+ class User(Base):
9
+ __tablename__ = "users"
10
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
11
+ email = Column(String(320), nullable=False)
12
+ password_hash = Column(String(255), nullable=False)
13
+ role = Column(String(20), nullable=False, default="owner")
14
+ is_active = Column(Boolean, nullable=False, default=True)
15
+ created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
16
+
17
+ class Hub(Base):
18
+ __tablename__ = "hubs"
19
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
20
+ owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
21
+ name = Column(String(120), nullable=False)
22
+ description = Column(String(500), nullable=True)
23
+ ai_summary = Column(String, nullable=True)
24
+ suggested_questions = Column(JSONB, nullable=False, default=list)
25
+ created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
26
+ updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
27
+
28
+ __table_args__ = (
29
+ UniqueConstraint("owner_id", "name", name="uq_hub_name_per_owner"),
30
+ )
31
+
32
+ class Document(Base):
33
+ __tablename__ = "documents"
34
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
35
+ hub_id = Column(UUID(as_uuid=True), ForeignKey("hubs.id", ondelete="CASCADE"), nullable=False)
36
+ owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
37
+ origin = Column(String(20), nullable=False)
38
+ filename = Column(String(255), nullable=False)
39
+ mime_type = Column(String(100), nullable=False)
40
+ size_bytes = Column(Integer, nullable=False)
41
+ checksum_sha256 = Column(String(64), nullable=False)
42
+ status = Column(String(20), nullable=False, default="pending")
43
+ error_message = Column(String, nullable=True)
44
+ file_bytes = Column(LargeBinary, nullable=True)
45
+ process_claim = Column(UUID(as_uuid=True), nullable=True)
46
+ created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
47
+ updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
48
+
49
+ __table_args__ = (
50
+ UniqueConstraint("hub_id", "checksum_sha256", name="uq_doc_checksum_per_hub"),
51
+ )
52
+
53
+ class Chunk(Base):
54
+ __tablename__ = "chunks"
55
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
56
+ document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False)
57
+ hub_id = Column(UUID(as_uuid=True), ForeignKey("hubs.id", ondelete="CASCADE"), nullable=False)
58
+ owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
59
+ chunk_index = Column(Integer, nullable=False)
60
+ text = Column(String, nullable=False)
61
+ token_count = Column(Integer, nullable=False)
62
+ embedding = Column(Vector(768), nullable=False)
63
+ created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
64
+
65
+ class IdempotencyKey(Base):
66
+ __tablename__ = "idempotency_keys"
67
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
68
+ key = Column(UUID(as_uuid=True), primary_key=True)
69
+ request_method = Column(String(10), nullable=False)
70
+ request_path = Column(String(255), nullable=False)
71
+ request_hash = Column(String(64), nullable=False)
72
+ status = Column(String(20), nullable=False, default="in_progress")
73
+ response_status = Column(Integer, nullable=True)
74
+ response_body = Column(JSONB, nullable=True)
75
+ created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
76
+ expires_at = Column(DateTime(timezone=True), nullable=False)
app/routers/auth.py CHANGED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, status
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy.exc import IntegrityError
4
+ from app.db import get_db
5
+ from app.models import User
6
+ from app.schemas import UserCreate, UserResponse
7
+ from app.security import hash_password
8
+ from app.errors import AppError
9
+
10
+ router = APIRouter(prefix="/auth", tags=["auth"])
11
+
12
+ @router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
13
+ def signup(user_data: UserCreate, db: Session = Depends(get_db)):
14
+ # 1. Check if email exists (using lower() for case-insensitivity)
15
+ existing_user = db.query(User).filter(User.email.ilike(user_data.email)).first()
16
+ if existing_user:
17
+ raise AppError(
18
+ status_code=409,
19
+ code="EMAIL_ALREADY_REGISTERED",
20
+ message="An account with this email already exists.",
21
+ )
22
+
23
+ # 2. Hash password and create user
24
+ new_user = User(
25
+ email=user_data.email,
26
+ password_hash=hash_password(user_data.password)
27
+ )
28
+
29
+ try:
30
+ db.add(new_user)
31
+ db.commit()
32
+ db.refresh(new_user)
33
+ except IntegrityError:
34
+ # Catch race conditions where two requests hit at the exact same time
35
+ db.rollback()
36
+ raise AppError(
37
+ status_code=409,
38
+ code="EMAIL_ALREADY_REGISTERED",
39
+ message="An account with this email already exists.",
40
+ )
41
+
42
+ return new_user
app/schemas.py CHANGED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+ from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
4
+
5
+ class UserCreate(BaseModel):
6
+ model_config = ConfigDict(extra="forbid")
7
+ email: EmailStr
8
+ password: str = Field(min_length=8, max_length=128)
9
+
10
+ @field_validator("password")
11
+ @classmethod
12
+ def password_strength(cls, v: str) -> str:
13
+ if not any(c.isdigit() for c in v) or not any(c.isalpha() for c in v):
14
+ raise ValueError("must contain at least one letter and one digit")
15
+ if len(v.encode("utf-8")) > 72:
16
+ raise ValueError("must be at most 72 bytes when encoded as UTF-8")
17
+ return v
18
+
19
+ class UserResponse(BaseModel):
20
+ model_config = ConfigDict(from_attributes=True)
21
+ id: UUID
22
+ email: EmailStr
23
+ role: str
24
+ created_at: datetime
app/security.py CHANGED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import bcrypt
2
+ import jwt
3
+ from datetime import datetime, timedelta, timezone
4
+ from uuid import UUID
5
+ from app.config import settings
6
+
7
+ # A dummy hash used to prevent timing attacks when an email doesn't exist
8
+ _DUMMY_HASH = bcrypt.hashpw(b"not-a-real-password", bcrypt.gensalt()).decode()
9
+
10
+ def hash_password(plain: str) -> str:
11
+ return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
12
+
13
+ def verify_password(plain: str, stored_hash: str | None) -> bool:
14
+ # Always runs one bcrypt comparison, even if stored_hash is None
15
+ ok = bcrypt.checkpw(plain.encode(), (stored_hash or _DUMMY_HASH).encode())
16
+ return ok and stored_hash is not None
17
+
18
+ def create_access_token(user_id: UUID, role: str) -> str:
19
+ now = datetime.now(timezone.utc)
20
+ payload = {
21
+ "sub": str(user_id),
22
+ "role": role,
23
+ "iat": now,
24
+ "exp": now + timedelta(minutes=60),
25
+ }
26
+ return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
tests/test_auth.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_signup_valid(client):
2
+ response = client.post("/api/v1/auth/signup", json={
3
+ "email": "test@example.com",
4
+ "password": "ValidPass123"
5
+ })
6
+ assert response.status_code == 201
7
+ data = response.json()
8
+ assert data["email"] == "test@example.com"
9
+ assert data["role"] == "owner"
10
+ assert "password_hash" not in data
11
+ assert "id" in data
12
+
13
+ def test_signup_duplicate_email_case_insensitive(client):
14
+ # First signup
15
+ client.post("/api/v1/auth/signup", json={
16
+ "email": "Test@Example.com",
17
+ "password": "ValidPass123"
18
+ })
19
+ # Second signup with different casing
20
+ response = client.post("/api/v1/auth/signup", json={
21
+ "email": "test@example.com",
22
+ "password": "ValidPass123"
23
+ })
24
+ assert response.status_code == 409
25
+ assert response.json()["error"]["code"] == "EMAIL_ALREADY_REGISTERED"
26
+
27
+ def test_signup_weak_password(client):
28
+ response = client.post("/api/v1/auth/signup", json={
29
+ "email": "weak@example.com",
30
+ "password": "abc"
31
+ })
32
+ assert response.status_code == 422
33
+ data = response.json()
34
+ assert data["error"]["code"] == "VALIDATION_ERROR"
35
+ # Check that the field is mapped correctly
36
+ fields = [d["field"] for d in data["error"]["details"]]
37
+ assert "body.password" in fields
38
+
39
+ def test_signup_password_over_72_bytes(client):
40
+ # 40 emojis * 4 bytes = 160 bytes
41
+ emoji_password = "😀" * 40
42
+ response = client.post("/api/v1/auth/signup", json={
43
+ "email": "emoji@example.com",
44
+ "password": emoji_password
45
+ })
46
+ assert response.status_code == 422
47
+ data = response.json()
48
+ fields = [d["field"] for d in response.json()["error"]["details"]]
49
+ assert "body.password" in fields
50
+
51
+ def test_signup_extra_field_forbidden(client):
52
+ response = client.post("/api/v1/auth/signup", json={
53
+ "email": "hacker@example.com",
54
+ "password": "ValidPass123",
55
+ "role": "admin" # This should be forbidden!
56
+ })
57
+ assert response.status_code == 422
58
+ data = response.json()
59
+ assert data["error"]["code"] == "VALIDATION_ERROR"
60
+ fields = [d["field"] for d in data["error"]["details"]]
61
+ assert "body.role" in fields
tests/test_errors.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_404_returns_envelope(client):
2
+ response = client.get("/nope")
3
+ assert response.status_code == 404
4
+
5
+ data = response.json()
6
+ assert "error" in data
7
+ assert data["error"]["code"] == "ROUTE_NOT_FOUND"
8
+ assert data["error"]["status"] == 404
9
+ assert "request_id" in data["error"]
10
+
11
+ def test_422_returns_field_details(client):
12
+ # We will create a temporary endpoint just for this test to trigger a 422
13
+ from app.main import app
14
+ from pydantic import BaseModel
15
+
16
+ class DummySchema(BaseModel):
17
+ email: str
18
+
19
+ @app.post("/test-validation")
20
+ def test_route(data: DummySchema):
21
+ return {"ok": True}
22
+
23
+ response = client.post("/test-validation", json={"wrong_field": "value"})
24
+ assert response.status_code == 422
25
+
26
+ data = response.json()
27
+ assert data["error"]["code"] == "VALIDATION_ERROR"
28
+ assert data["error"]["details"][0]["field"] == "body.email"
29
+ assert data["error"]["details"][0]["reason"] == "missing"
30
+
31
+ def test_request_id_header_present(client):
32
+ response = client.get("/healthz")
33
+ assert "X-Request-ID" in response.headers
34
+
35
+ def test_unhandled_exception_returns_500_envelope(client):
36
+ from app.main import app
37
+ @app.get("/test-crash")
38
+ def crash_route():
39
+ raise ValueError("Boom!")
40
+
41
+ response = client.get("/test-crash")
42
+ assert response.status_code == 500
43
+ data = response.json()
44
+ assert data["error"]["code"] == "INTERNAL_ERROR"
45
+ # Ensure no traceback leaks
46
+ assert "Traceback" not in response.text
47
+ assert "ValueError" not in response.text