-
Notifications
You must be signed in to change notification settings - Fork 76
Feat: Added User Authentication and Authorization #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "python-envs.defaultEnvManager": "ms-python.python:system", | ||
| "python-envs.pythonProjects": [] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| .env | ||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||
|
|
||
| # dependencies | ||
| /node_modules | ||
|
|
||
| # next.js | ||
| /.next/ | ||
| /out/ | ||
|
|
||
| # production | ||
| /build | ||
|
|
||
| # debug | ||
| npm-debug.log* | ||
| yarn-debug.log* | ||
| yarn-error.log* | ||
| .pnpm-debug.log* | ||
|
|
||
| # env files | ||
| .env* | ||
|
|
||
| # vercel | ||
| .vercel | ||
|
|
||
| # typescript | ||
| *.tsbuildinfo | ||
| next-env.d.ts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import os | ||
| from typing import Optional | ||
|
|
||
| from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase | ||
|
|
||
|
|
||
| _client: Optional[AsyncIOMotorClient] = None | ||
| _db: Optional[AsyncIOMotorDatabase] = None | ||
|
|
||
|
|
||
| def get_mongo_uri() -> str: | ||
| # Expect a MongoDB connection string in environment (Atlas URI) | ||
| return os.getenv("MONGODB_URI", "mongodb://localhost:27017") | ||
|
|
||
|
|
||
| def init_mongo(app=None) -> None: | ||
| global _client, _db | ||
| if _client is None: | ||
| uri = get_mongo_uri() | ||
| _client = AsyncIOMotorClient(uri) | ||
| # default database name | ||
| db_name = os.getenv("MONGODB_DB", "perspective") | ||
| _db = _client[db_name] | ||
|
|
||
|
|
||
| def close_mongo() -> None: | ||
| global _client, _db | ||
| if _client is not None: | ||
| _client.close() | ||
| # Reset globals so future calls re-init a fresh client instead of returning closed handle | ||
| _client = None | ||
| _db = None | ||
|
|
||
|
|
||
| def get_db() -> AsyncIOMotorDatabase: | ||
| if _db is None: | ||
| init_mongo() | ||
| return _db |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| from typing import Optional | ||
| from datetime import datetime | ||
|
|
||
| from app.models.user import User | ||
| from app.db.mongo import get_db | ||
|
|
||
|
|
||
| async def get_user_by_email(email: str) -> Optional[User]: | ||
| db = get_db() | ||
| doc = await db.users.find_one({"email": email}) | ||
| if not doc: | ||
| return None | ||
| # convert Mongo's _id and possible datetime | ||
| if "_id" in doc: | ||
| doc["id"] = str(doc.pop("_id")) | ||
| return User(**doc) | ||
|
|
||
|
|
||
| async def create_user(user: User) -> User: | ||
| db = get_db() | ||
| existing = await db.users.find_one({"email": user.email}) | ||
| if existing: | ||
| raise ValueError("User with this email already exists") | ||
| payload = user.model_dump() | ||
| # store created_at as datetime | ||
| if isinstance(payload.get("created_at"), str): | ||
| try: | ||
| payload["created_at"] = datetime.fromisoformat(payload["created_at"]) | ||
| except Exception: | ||
| payload["created_at"] = datetime.utcnow() | ||
| result = await db.users.insert_one(payload) | ||
| payload["id"] = str(result.inserted_id) | ||
| return User(**payload) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """User models for authentication.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| from pydantic import BaseModel, EmailStr, Field | ||
| from typing import Optional | ||
| from datetime import datetime, timezone | ||
| from uuid import uuid4 | ||
|
|
||
|
|
||
| class UserCreate(BaseModel): | ||
| name: str | ||
| email: EmailStr | ||
| password: str | ||
|
|
||
|
|
||
| class User(BaseModel): | ||
| id: str = Field(default_factory=lambda: str(uuid4())) | ||
| name: str | ||
| email: EmailStr | ||
| hashed_password: str | ||
| created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) | ||
|
|
||
|
|
||
| class UserPublic(BaseModel): | ||
| id: str | ||
| name: str | ||
| email: EmailStr | ||
| created_at: datetime |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| from fastapi import APIRouter, HTTPException, status | ||
| from pydantic import BaseModel, EmailStr | ||
| from app.models.user import User, UserCreate, UserPublic | ||
| from app.db.user_store import get_user_by_email, create_user | ||
| from app.utils.auth import hash_password, verify_password, create_access_token | ||
|
|
||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| class SignupRequest(UserCreate): | ||
| pass | ||
|
|
||
|
|
||
| class LoginRequest(BaseModel): | ||
| email: EmailStr | ||
| password: str | ||
|
|
||
|
|
||
| def _validate_password_strength(password: str): | ||
| if len(password) < 8: | ||
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters long") | ||
|
|
||
|
|
||
| @router.post("/signup") | ||
| async def signup(body: SignupRequest): | ||
| _validate_password_strength(body.password) | ||
| existing = await get_user_by_email(body.email) | ||
| if existing: | ||
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") | ||
| user = User(name=body.name, email=body.email, hashed_password=hash_password(body.password)) | ||
| user = await create_user(user) | ||
| token = create_access_token(user.email) | ||
| return { | ||
| "access_token": token, | ||
| "token_type": "bearer", | ||
| "user": UserPublic(**user.model_dump()).model_dump(), | ||
| } | ||
|
|
||
|
|
||
| @router.post("/login") | ||
| async def login(body: LoginRequest): | ||
| # Timing attack mitigation: | ||
| # Always perform a password verification step even if user does not exist. | ||
| user = await get_user_by_email(body.email) | ||
| # Pre-generated dummy hash (bcrypt_sha256 of a constant) ensures constant-time path. | ||
| # We generate it lazily to avoid import-time work. | ||
| from app.utils.auth import hash_password as _hp, verify_password as _vp # local import to avoid circularity | ||
| dummy_hash = _hp("__dummy_constant_password__") | ||
| hashed = user.hashed_password if user else dummy_hash | ||
| password_ok = _vp(body.password, hashed) | ||
| if not user or not password_ok: | ||
| # Return generic error regardless of which check failed | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") | ||
| token = create_access_token(user.email) | ||
| return { | ||
| "access_token": token, | ||
| "token_type": "bearer", | ||
| "user": UserPublic(**user.model_dump()).model_dump(), | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,56 @@ | ||||||||||
| import os | ||||||||||
| from datetime import datetime, timedelta, timezone | ||||||||||
| from typing import Optional | ||||||||||
|
|
||||||||||
| import jwt | ||||||||||
| from fastapi import Depends, HTTPException, status | ||||||||||
| from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer | ||||||||||
| from passlib.context import CryptContext | ||||||||||
|
|
||||||||||
| JWT_SECRET = os.getenv("JWT_SECRET", "change-me") | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Weak default JWT secret exposes token forgery risk. The default Apply this diff to fail fast when the secret is not configured: -JWT_SECRET = os.getenv("JWT_SECRET", "change-me")
+JWT_SECRET = os.getenv("JWT_SECRET")
+if not JWT_SECRET:
+ raise ValueError("JWT_SECRET environment variable must be set")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| ALGORITHM = "HS256" | ||||||||||
| ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "120")) | ||||||||||
|
|
||||||||||
| pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto") | ||||||||||
| bearer_scheme = HTTPBearer(auto_error=True) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def hash_password(password: str) -> str: | ||||||||||
| # passlib's bcrypt_sha256 handles long passwords safely. | ||||||||||
| return pwd_context.hash(password) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def verify_password(password: str, hashed: str) -> bool: | ||||||||||
| return pwd_context.verify(password, hashed) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def create_access_token(subject: str) -> str: | ||||||||||
| now = datetime.now(timezone.utc) | ||||||||||
| payload = { | ||||||||||
| "sub": subject, | ||||||||||
| "iat": int(now.timestamp()), | ||||||||||
| "exp": int((now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp()), | ||||||||||
| } | ||||||||||
| return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def decode_token(token: str) -> dict: | ||||||||||
| try: | ||||||||||
| return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) | ||||||||||
| except jwt.ExpiredSignatureError: | ||||||||||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") | ||||||||||
| except jwt.PyJWTError: | ||||||||||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") | ||||||||||
|
|
||||||||||
|
|
||||||||||
| async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict: | ||||||||||
| token = credentials.credentials | ||||||||||
| payload = decode_token(token) | ||||||||||
| from app.db.user_store import get_user_by_email # local import to avoid circulars | ||||||||||
| email = payload.get("sub") | ||||||||||
| if not email: | ||||||||||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") | ||||||||||
| user = await get_user_by_email(email) | ||||||||||
| if not user: | ||||||||||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists") | ||||||||||
| return {"email": email} | ||||||||||
Uh oh!
There was an error while loading. Please reload this page.