From 6bcea3ee5d2930da3b08f1e4ec02dba2d9ec6244 Mon Sep 17 00:00:00 2001 From: NPS Agent Date: Tue, 12 May 2026 09:22:59 +0930 Subject: [PATCH] Nginx now listens for path /api and redirects internally, changed BaseURL to use /api, AND patched security authentication issue for POST and GET requests to the uvicorn service --- PROGRESS.md | 7 +++---- api.js | 2 +- backend/auth.py | 26 +++++++++++++++++++++++++- backend/main.py | 22 +++++++++++----------- dashy.db | Bin 69632 -> 69632 bytes 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 61906f4..3b0d435 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -63,10 +63,9 @@ - **API client:** Added `api.changePassword(id, oldPwd, newPwd)` that surfaces the backend's `detail` message inline rather than just the HTTP status text. - **Frontend:** Wired the previously-inert "Update password" button in `SettingsScreen` — submits via `onChangePassword`, shows inline error / success states, disables while in flight, clears the fields on success, and writes a `password_changed` audit entry. - **Defaults confirmed:** Seeded users (`seed.py`) and admin-created users (`app.jsx`) both default to `password123`. -15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. - - The password input on `LoginScreen` was a decorative `defaultValue` field — the button submitted with no password, and `onLogin` had a fallback default of `"password123"` which matched every seeded account. - - Bound the input to component state, send the actual typed password to `api.login`, and let backend `401`s propagate so the screen can render an inline "Incorrect password" message instead of silently letting anyone in. - - Enter key now submits, and the button disables while the request is in flight. +15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. Bound the input to component state and implemented proper 401 handling with inline error messaging. +16. **Network Hardening:** Configured the frontend to use a relative `/api` path, allowing the FastAPI backend to be completely shielded behind an Nginx SSL reverse proxy on `127.0.0.1`. No internal ports are now exposed to the public internet. +17. **API Authentication Enforcement:** Fixed a security vulnerability where API endpoints were publicly accessible without a token. Implemented the `get_current_user` dependency in `backend/auth.py` and applied it to all sensitive routes. Accessing `/tasks`, `/users`, etc. now strictly requires a valid JWT Bearer token. ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. diff --git a/api.js b/api.js index ea7b3f8..b0ba2e5 100644 --- a/api.js +++ b/api.js @@ -1,6 +1,6 @@ class ApiService { constructor() { - this.baseUrl = `http://${window.location.hostname}:24024`; + this.baseUrl = '/api'; this.token = localStorage.getItem('dashy_token'); this.subscribers = new Set(); } diff --git a/backend/auth.py b/backend/auth.py index bacb129..cab33ff 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -2,12 +2,17 @@ from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from . import models, database # SECRET_KEY should be in an environment variable in production SECRET_KEY = "your-secret-key-change-this-in-production" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def verify_password(plain_password, hashed_password): @@ -21,7 +26,26 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): if expires_delta: expire = datetime.utcnow() + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=15) + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(database.get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(models.User).filter(models.User.id == user_id).first() + if user is None: + raise credentials_exception + return user diff --git a/backend/main.py b/backend/main.py index 171958d..e4d847f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,11 +32,11 @@ async def login_for_access_token(form_data: schemas.UserLogin, db: Session = Dep return {"access_token": access_token, "token_type": "bearer"} @app.get("/users", response_model=List[schemas.User]) -def read_users(db: Session = Depends(get_db)): +def read_users(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): return db.query(models.User).all() @app.post("/users", response_model=schemas.User) -def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): +def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_user = models.User( id=user.id, name=user.name, @@ -55,7 +55,7 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): return db_user @app.patch("/users/{user_id}", response_model=schemas.User) -def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db)): +def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -69,7 +69,7 @@ def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Dep return db_user @app.post("/users/{user_id}/password") -def change_password(user_id: str, payload: schemas.PasswordChange, db: Session = Depends(get_db)): +def change_password(user_id: str, payload: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -80,7 +80,7 @@ def change_password(user_id: str, payload: schemas.PasswordChange, db: Session = return {"message": "Password updated"} @app.delete("/users/{user_id}") -def delete_user(user_id: str, db: Session = Depends(get_db)): +def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -93,11 +93,11 @@ def delete_user(user_id: str, db: Session = Depends(get_db)): return {"message": "User deleted"} @app.get("/tasks", response_model=List[schemas.Task]) -def read_tasks(db: Session = Depends(get_db)): +def read_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): return db.query(models.Task).all() @app.post("/tasks", response_model=schemas.Task) -def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): +def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): task_id = task.id or f"t_{uuid.uuid4().hex[:8]}" db_task = models.Task( id=task_id, @@ -125,7 +125,7 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): return db_task @app.patch("/tasks/{task_id}", response_model=schemas.Task) -def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db)): +def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_task = db.query(models.Task).filter(models.Task.id == task_id).first() if not db_task: raise HTTPException(status_code=404, detail="Task not found") @@ -139,7 +139,7 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep return db_task @app.delete("/tasks/{task_id}") -def delete_task(task_id: str, db: Session = Depends(get_db)): +def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): db_task = db.query(models.Task).filter(models.Task.id == task_id).first() if not db_task: raise HTTPException(status_code=404, detail="Task not found") @@ -148,11 +148,11 @@ def delete_task(task_id: str, db: Session = Depends(get_db)): db.commit() return {"message": "Task deleted"} @app.get("/audit", response_model=List[schemas.AuditLog]) -def read_audit(db: Session = Depends(get_db)): +def read_audit(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all() @app.post("/audit", response_model=schemas.AuditLog) -def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db)): +def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): audit_id = f"a_{uuid.uuid4().hex[:8]}" db_audit = models.AuditLog( id=audit_id, diff --git a/dashy.db b/dashy.db index cebf391f30d134071dc2e3b63e5b7a12585e5c8d..8eb3b0c1e644c0df3114706ac9d6c7d43e0ed2c7 100644 GIT binary patch delta 150 zcmZozz|ydQWr8$g-b5K^#=MOQOX8XBctO5h6Y9XDLMJ+nR&sP>3OLs3YmG^fAcW1G6MiW-Yek% delta 82 zcmV-Y0ImOkpag)R1dtm6ZIK*90d28hq+bI)433irU?;OMU{4H@pb?XQYzDKGY;cnr o3k>@J52X)Q4&n}}4sZ@R4ebrK4So$r4EwVo5Zw&7`wRgE1CbOP(EtDd