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

This commit is contained in:
NPS Agent
2026-05-12 09:22:59 +09:30
parent 69588de82c
commit 6bcea3ee5d
5 changed files with 40 additions and 17 deletions
+3 -4
View File
@@ -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. - **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. - **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`. - **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. 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.
- 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. 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.
- 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. 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.
- Enter key now submits, and the button disables while the request is in flight.
### Phase 3: Advanced Features ### Phase 3: Advanced Features
- **Real-time Notifications:** Explore WebSockets for task assignments. - **Real-time Notifications:** Explore WebSockets for task assignments.
+1 -1
View File
@@ -1,6 +1,6 @@
class ApiService { class ApiService {
constructor() { constructor() {
this.baseUrl = `http://${window.location.hostname}:24024`; this.baseUrl = '/api';
this.token = localStorage.getItem('dashy_token'); this.token = localStorage.getItem('dashy_token');
this.subscribers = new Set(); this.subscribers = new Set();
} }
+25 -1
View File
@@ -2,12 +2,17 @@ from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext 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 should be in an environment variable in production
SECRET_KEY = "your-secret-key-change-this-in-production" SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password): 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: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
else: else:
expire = datetime.utcnow() + timedelta(minutes=15) expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt 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
+11 -11
View File
@@ -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"} return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users", response_model=List[schemas.User]) @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() return db.query(models.User).all()
@app.post("/users", response_model=schemas.User) @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( db_user = models.User(
id=user.id, id=user.id,
name=user.name, name=user.name,
@@ -55,7 +55,7 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
return db_user return db_user
@app.patch("/users/{user_id}", response_model=schemas.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() db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") 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 return db_user
@app.post("/users/{user_id}/password") @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() db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") 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"} return {"message": "Password updated"}
@app.delete("/users/{user_id}") @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() db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") 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"} return {"message": "User deleted"}
@app.get("/tasks", response_model=List[schemas.Task]) @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() return db.query(models.Task).all()
@app.post("/tasks", response_model=schemas.Task) @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]}" task_id = task.id or f"t_{uuid.uuid4().hex[:8]}"
db_task = models.Task( db_task = models.Task(
id=task_id, id=task_id,
@@ -125,7 +125,7 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
return db_task return db_task
@app.patch("/tasks/{task_id}", response_model=schemas.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() db_task = db.query(models.Task).filter(models.Task.id == task_id).first()
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") 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 return db_task
@app.delete("/tasks/{task_id}") @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() db_task = db.query(models.Task).filter(models.Task.id == task_id).first()
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") 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() db.commit()
return {"message": "Task deleted"} return {"message": "Task deleted"}
@app.get("/audit", response_model=List[schemas.AuditLog]) @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() return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
@app.post("/audit", response_model=schemas.AuditLog) @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]}" audit_id = f"a_{uuid.uuid4().hex[:8]}"
db_audit = models.AuditLog( db_audit = models.AuditLog(
id=audit_id, id=audit_id,
BIN
View File
Binary file not shown.