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:
+3
-4
@@ -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,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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user