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

feat(auth): implement login, jwt guard, and change password (#4)

Browse files

* feat(auth): add schemas, jwt guard, and change password endpoint

* feat(api): register auth routes and protected test route

* test(auth): cover 401 matrix, login timing, and change password

Files changed (5) hide show
  1. app/deps.py +45 -0
  2. app/main.py +8 -3
  3. app/routers/auth.py +35 -6
  4. app/schemas.py +24 -1
  5. tests/test_auth.py +110 -39
app/deps.py CHANGED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import UUID
2
+ import jwt
3
+ from fastapi import Depends
4
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
5
+ from sqlalchemy.orm import Session
6
+ from app.config import settings
7
+ from app.db import get_db
8
+ from app.models import User
9
+ from app.errors import AppError
10
+
11
+ bearer_scheme = HTTPBearer(auto_error=False)
12
+
13
+ def get_current_user(
14
+ creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
15
+ db: Session = Depends(get_db)
16
+ ) -> User:
17
+ if creds is None or creds.scheme.lower() != "bearer":
18
+ raise AppError(
19
+ 401, "AUTH_MISSING",
20
+ "Authorization header with a Bearer token is required.",
21
+ headers={"WWW-Authenticate": "Bearer"}
22
+ )
23
+
24
+ try:
25
+ payload = jwt.decode(
26
+ creds.credentials,
27
+ settings.JWT_SECRET_KEY,
28
+ algorithms=["HS256"],
29
+ options={"require": ["exp", "sub"]}
30
+ )
31
+ except jwt.ExpiredSignatureError:
32
+ raise AppError(401, "TOKEN_EXPIRED", "Access token has expired. Log in again.")
33
+ except jwt.PyJWTError:
34
+ raise AppError(401, "TOKEN_INVALID", "Access token is malformed or has an invalid signature.")
35
+
36
+ try:
37
+ user_id = UUID(payload["sub"])
38
+ except (ValueError, KeyError):
39
+ raise AppError(401, "TOKEN_INVALID", "Token subject is not a valid user id.")
40
+
41
+ user = db.get(User, user_id)
42
+ if user is None or not user.is_active:
43
+ raise AppError(401, "TOKEN_INVALID", "Token does not map to an active user.")
44
+
45
+ return user
app/main.py CHANGED
@@ -1,10 +1,12 @@
1
  import uuid
2
  import logging
3
- 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
- from app.routers import auth # <-- IMPORT THE ROUTER
 
 
8
 
9
  logger = logging.getLogger("uvicorn.error")
10
 
@@ -16,7 +18,6 @@ def create_app() -> FastAPI:
16
  redoc_url=None,
17
  )
18
 
19
- # Register the router
20
  app.include_router(auth.router, prefix="/api/v1")
21
 
22
  @app.middleware("http")
@@ -62,6 +63,10 @@ def create_app() -> FastAPI:
62
  @app.get("/healthz", tags=["system"])
63
  def healthz() -> dict[str, str]:
64
  return {"status": "ok"}
 
 
 
 
65
 
66
  return app
67
 
 
1
  import uuid
2
  import logging
3
+ from fastapi import FastAPI, Request, Depends
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
8
+ from app.deps import get_current_user
9
+ from app.models import User
10
 
11
  logger = logging.getLogger("uvicorn.error")
12
 
 
18
  redoc_url=None,
19
  )
20
 
 
21
  app.include_router(auth.router, prefix="/api/v1")
22
 
23
  @app.middleware("http")
 
63
  @app.get("/healthz", tags=["system"])
64
  def healthz() -> dict[str, str]:
65
  return {"status": "ok"}
66
+
67
+ @app.get("/api/v1/protected", tags=["system"])
68
+ def protected_route(current_user: User = Depends(get_current_user)):
69
+ return {"message": "You are authenticated!", "user_id": str(current_user.id)}
70
 
71
  return app
72
 
app/routers/auth.py CHANGED
@@ -3,15 +3,15 @@ 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(
@@ -20,7 +20,6 @@ def signup(user_data: UserCreate, db: Session = Depends(get_db)):
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)
@@ -31,7 +30,6 @@ def signup(user_data: UserCreate, db: Session = Depends(get_db)):
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,
@@ -39,4 +37,35 @@ def signup(user_data: UserCreate, db: Session = Depends(get_db)):
39
  message="An account with this email already exists.",
40
  )
41
 
42
- return new_user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, UserLogin, TokenResponse, ChangePasswordRequest
7
+ from app.security import hash_password, verify_password, create_access_token
8
  from app.errors import AppError
9
+ from app.deps import get_current_user
10
 
11
  router = APIRouter(prefix="/auth", tags=["auth"])
12
 
13
  @router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
14
  def signup(user_data: UserCreate, db: Session = Depends(get_db)):
 
15
  existing_user = db.query(User).filter(User.email.ilike(user_data.email)).first()
16
  if existing_user:
17
  raise AppError(
 
20
  message="An account with this email already exists.",
21
  )
22
 
 
23
  new_user = User(
24
  email=user_data.email,
25
  password_hash=hash_password(user_data.password)
 
30
  db.commit()
31
  db.refresh(new_user)
32
  except IntegrityError:
 
33
  db.rollback()
34
  raise AppError(
35
  status_code=409,
 
37
  message="An account with this email already exists.",
38
  )
39
 
40
+ return new_user
41
+
42
+ @router.post("/login", response_model=TokenResponse)
43
+ def login(credentials: UserLogin, db: Session = Depends(get_db)):
44
+ user = db.query(User).filter(User.email.ilike(credentials.email)).first()
45
+
46
+ if not verify_password(credentials.password, user.password_hash if user else None):
47
+ raise AppError(401, "INVALID_CREDENTIALS", "Incorrect email or password.")
48
+
49
+ if not user.is_active:
50
+ raise AppError(401, "INVALID_CREDENTIALS", "Incorrect email or password.")
51
+
52
+ token = create_access_token(user.id, user.role)
53
+ return {"access_token": token, "token_type": "bearer", "expires_in": 3600}
54
+
55
+ @router.put("/change-password", status_code=status.HTTP_200_OK)
56
+ def change_password(
57
+ data: ChangePasswordRequest,
58
+ current_user: User = Depends(get_current_user),
59
+ db: Session = Depends(get_db)
60
+ ):
61
+ if not verify_password(data.current_password, current_user.password_hash):
62
+ raise AppError(
63
+ 401,
64
+ "INVALID_CREDENTIALS",
65
+ "The current password provided is incorrect."
66
+ )
67
+
68
+ current_user.password_hash = hash_password(data.new_password)
69
+ db.commit()
70
+
71
+ return {"message": "Password updated successfully."}
app/schemas.py CHANGED
@@ -16,9 +16,32 @@ class UserCreate(BaseModel):
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  raise ValueError("must be at most 72 bytes when encoded as UTF-8")
17
  return v
18
 
19
+ class UserLogin(BaseModel):
20
+ email: EmailStr
21
+ password: str
22
+
23
+ class TokenResponse(BaseModel):
24
+ access_token: str
25
+ token_type: str = "bearer"
26
+ expires_in: int
27
+
28
  class UserResponse(BaseModel):
29
  model_config = ConfigDict(from_attributes=True)
30
  id: UUID
31
  email: EmailStr
32
  role: str
33
+ created_at: datetime
34
+
35
+ class ChangePasswordRequest(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ current_password: str
38
+ new_password: str = Field(min_length=8, max_length=128)
39
+
40
+ @field_validator("new_password")
41
+ @classmethod
42
+ def password_strength(cls, v: str) -> str:
43
+ if not any(c.isdigit() for c in v) or not any(c.isalpha() for c in v):
44
+ raise ValueError("must contain at least one letter and one digit")
45
+ if len(v.encode("utf-8")) > 72:
46
+ raise ValueError("must be at most 72 bytes when encoded as UTF-8")
47
+ return v
tests/test_auth.py CHANGED
@@ -1,61 +1,132 @@
 
 
 
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ import jwt
3
+ from app.config import settings
4
+
5
  def test_signup_valid(client):
6
+ response = client.post("/api/v1/auth/signup", json={"email": "test@example.com", "password": "ValidPass123"})
 
 
 
7
  assert response.status_code == 201
8
  data = response.json()
9
  assert data["email"] == "test@example.com"
10
  assert data["role"] == "owner"
11
  assert "password_hash" not in data
 
12
 
13
  def test_signup_duplicate_email_case_insensitive(client):
14
+ client.post("/api/v1/auth/signup", json={"email": "Test@Example.com", "password": "ValidPass123"})
15
+ response = client.post("/api/v1/auth/signup", json={"email": "test@example.com", "password": "ValidPass123"})
 
 
 
 
 
 
 
 
16
  assert response.status_code == 409
17
  assert response.json()["error"]["code"] == "EMAIL_ALREADY_REGISTERED"
18
 
19
  def test_signup_weak_password(client):
20
+ response = client.post("/api/v1/auth/signup", json={"email": "weak@example.com", "password": "abc"})
 
 
 
21
  assert response.status_code == 422
22
+ fields = [d["field"] for d in response.json()["error"]["details"]]
 
 
 
23
  assert "body.password" in fields
24
 
25
  def test_signup_password_over_72_bytes(client):
26
+ response = client.post("/api/v1/auth/signup", json={"email": "emoji@example.com", "password": "😀" * 40})
 
 
 
 
 
27
  assert response.status_code == 422
 
28
  fields = [d["field"] for d in response.json()["error"]["details"]]
29
  assert "body.password" in fields
30
 
31
  def test_signup_extra_field_forbidden(client):
32
+ response = client.post("/api/v1/auth/signup", json={"email": "hacker@example.com", "password": "ValidPass123", "role": "admin"})
 
 
 
 
33
  assert response.status_code == 422
34
+ fields = [d["field"] for d in response.json()["error"]["details"]]
35
+ assert "body.role" in fields
36
+
37
+ def test_login_valid(client):
38
+ client.post("/api/v1/auth/signup", json={"email": "login@example.com", "password": "ValidPass123"})
39
+ response = client.post("/api/v1/auth/login", json={"email": "login@example.com", "password": "ValidPass123"})
40
+ assert response.status_code == 200
41
+ assert "access_token" in response.json()
42
+
43
+ def test_login_wrong_password_and_unknown_email_look_identical(client):
44
+ client.post("/api/v1/auth/signup", json={"email": "real@example.com", "password": "ValidPass123"})
45
+ r1 = client.post("/api/v1/auth/login", json={"email": "real@example.com", "password": "WrongPass123"})
46
+ r2 = client.post("/api/v1/auth/login", json={"email": "unknown@example.com", "password": "ValidPass123"})
47
+
48
+ assert r1.status_code == 401
49
+ assert r2.status_code == 401
50
+
51
+ # Compare the error fields, ignoring the request_id (which is unique per request)
52
+ err1 = r1.json()["error"]
53
+ err2 = r2.json()["error"]
54
+
55
+ assert err1["code"] == err2["code"]
56
+ assert err1["message"] == err2["message"]
57
+ assert err1["status"] == err2["status"]
58
+ assert err1["details"] == err2["details"]
59
+ def test_protected_route_no_header(client):
60
+ response = client.get("/api/v1/protected")
61
+ assert response.status_code == 401
62
+ assert response.json()["error"]["code"] == "AUTH_MISSING"
63
+
64
+ def test_protected_route_garbage_token(client):
65
+ response = client.get("/api/v1/protected", headers={"Authorization": "Bearer not.a.jwt"})
66
+ assert response.status_code == 401
67
+ assert response.json()["error"]["code"] == "TOKEN_INVALID"
68
+
69
+ def test_protected_route_expired_token(client):
70
+ client.post("/api/v1/auth/signup", json={"email": "expired@example.com", "password": "ValidPass123"})
71
+ login = client.post("/api/v1/auth/login", json={"email": "expired@example.com", "password": "ValidPass123"})
72
+ user_id = jwt.decode(login.json()["access_token"], options={"verify_signature": False})["sub"]
73
+
74
+ now = datetime.now(timezone.utc)
75
+ expired_token = jwt.encode(
76
+ {"sub": user_id, "role": "owner", "iat": now - timedelta(hours=2), "exp": now - timedelta(hours=1)},
77
+ settings.JWT_SECRET_KEY, algorithm="HS256"
78
+ )
79
+ response = client.get("/api/v1/protected", headers={"Authorization": f"Bearer {expired_token}"})
80
+ assert response.status_code == 401
81
+ assert response.json()["error"]["code"] == "TOKEN_EXPIRED"
82
+
83
+ def test_protected_route_valid_token(client):
84
+ client.post("/api/v1/auth/signup", json={"email": "valid@example.com", "password": "ValidPass123"})
85
+ login = client.post("/api/v1/auth/login", json={"email": "valid@example.com", "password": "ValidPass123"})
86
+ response = client.get("/api/v1/protected", headers={"Authorization": f"Bearer {login.json()['access_token']}"})
87
+ assert response.status_code == 200
88
+
89
+ def test_change_password_success_and_login(client):
90
+ client.post("/api/v1/auth/signup", json={"email": "change@example.com", "password": "OldPass123"})
91
+ login = client.post("/api/v1/auth/login", json={"email": "change@example.com", "password": "OldPass123"})
92
+ token = login.json()["access_token"]
93
+
94
+ response = client.put(
95
+ "/api/v1/auth/change-password",
96
+ headers={"Authorization": f"Bearer {token}"},
97
+ json={"current_password": "OldPass123", "new_password": "NewPass456"}
98
+ )
99
+ assert response.status_code == 200
100
+
101
+ old_login = client.post("/api/v1/auth/login", json={"email": "change@example.com", "password": "OldPass123"})
102
+ assert old_login.status_code == 401
103
+
104
+ new_login = client.post("/api/v1/auth/login", json={"email": "change@example.com", "password": "NewPass456"})
105
+ assert new_login.status_code == 200
106
+
107
+ def test_change_password_wrong_current_password(client):
108
+ client.post("/api/v1/auth/signup", json={"email": "wrongcur@example.com", "password": "OldPass123"})
109
+ login = client.post("/api/v1/auth/login", json={"email": "wrongcur@example.com", "password": "OldPass123"})
110
+ response = client.put(
111
+ "/api/v1/auth/change-password",
112
+ headers={"Authorization": f"Bearer {login.json()['access_token']}"},
113
+ json={"current_password": "WrongOldPass", "new_password": "NewPass456"}
114
+ )
115
+ assert response.status_code == 401
116
+ assert response.json()["error"]["code"] == "INVALID_CREDENTIALS"
117
+
118
+ def test_change_password_requires_auth(client):
119
+ response = client.put("/api/v1/auth/change-password", json={"current_password": "OldPass123", "new_password": "NewPass456"})
120
+ assert response.status_code == 401
121
+ assert response.json()["error"]["code"] == "AUTH_MISSING"
122
+
123
+ def test_change_password_weak_new_password(client):
124
+ client.post("/api/v1/auth/signup", json={"email": "weaknew@example.com", "password": "OldPass123"})
125
+ login = client.post("/api/v1/auth/login", json={"email": "weaknew@example.com", "password": "OldPass123"})
126
+ response = client.put(
127
+ "/api/v1/auth/change-password",
128
+ headers={"Authorization": f"Bearer {login.json()['access_token']}"},
129
+ json={"current_password": "OldPass123", "new_password": "abc"}
130
+ )
131
+ assert response.status_code == 422
132
+ assert "body.new_password" in [d["field"] for d in response.json()["error"]["details"]]