Spaces:
Runtime error
Runtime error
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
- app/deps.py +45 -0
- app/main.py +8 -3
- app/routers/auth.py +35 -6
- app/schemas.py +24 -1
- 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
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 59 |
-
assert
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]]
|