Fix code review findings: security hardening and frontend fixes

Backend:
- Add rate limiting to login (5 attempts / 5 min window)
- Add secure flag to session cookies with helper function
- Add PIN min-length validation via Pydantic field_validator
- Fix naive datetime usage in todos.py (datetime.now() not UTC)
- Disable SQLAlchemy echo in production
- Remove auto-commit from get_db to prevent double commits
- Add lower bound filter to upcoming events query
- Add SECRET_KEY default warning on startup
- Remove create_all from lifespan (Alembic handles migrations)

Frontend:
- Fix ReminderForm remind_at slice for datetime-local input
- Add window.confirm() dialogs on all destructive actions
- Redirect authenticated users away from login screen
- Replace error: any with getErrorMessage helper in LockScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-16 07:49:21 +08:00
parent 81edf81d13
commit 1aaa2b3a74
11 changed files with 140 additions and 39 deletions

View File

@ -1,3 +1,4 @@
import sys
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -13,3 +14,9 @@ class Settings(BaseSettings):
settings = Settings()
if settings.SECRET_KEY == "your-secret-key-change-in-production":
print(
"WARNING: Using default SECRET_KEY. Set SECRET_KEY in .env for production.",
file=sys.stderr,
)

View File

@ -5,7 +5,7 @@ from app.config import settings
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=True,
echo=False,
future=True
)
@ -27,7 +27,6 @@ async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

View File

@ -2,17 +2,13 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import engine, Base
from app.database import engine
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: Clean up resources
await engine.dispose()
@ -26,7 +22,7 @@ app = FastAPI(
# CORS configuration for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@ -1,7 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from collections import defaultdict
import time
import bcrypt
from itsdangerous import TimestampSigner, BadSignature
@ -15,6 +17,29 @@ router = APIRouter()
# Initialize signer for session management
signer = TimestampSigner(app_settings.SECRET_KEY)
# Brute-force protection: track failed login attempts per IP
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_ATTEMPTS = 5
_WINDOW_SECONDS = 300 # 5-minute lockout window
def _check_rate_limit(ip: str) -> None:
"""Raise 429 if IP has exceeded failed login attempts."""
now = time.time()
attempts = _failed_attempts[ip]
# Prune old entries outside the window
_failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS]
if len(_failed_attempts[ip]) >= _MAX_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_failed_attempt(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
_failed_attempts[ip].append(time.time())
def hash_pin(pin: str) -> str:
"""Hash a PIN using bcrypt."""
@ -40,6 +65,18 @@ def verify_session_token(token: str) -> Optional[int]:
return None
def _set_session_cookie(response: Response, token: str) -> None:
"""Set the session cookie with secure defaults."""
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=True,
max_age=86400 * 30, # 30 days
samesite="lax",
)
async def get_current_session(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
@ -82,13 +119,7 @@ async def setup_pin(
# Create session
token = create_session_token(new_settings.id)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
_set_session_cookie(response, token)
return {"message": "Setup completed successfully", "authenticated": True}
@ -96,10 +127,14 @@ async def setup_pin(
@router.post("/login")
async def login(
data: SettingsCreate,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Verify PIN and create session."""
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
@ -107,17 +142,15 @@ async def login(
raise HTTPException(status_code=400, detail="Setup required")
if not verify_pin(data.pin, settings_obj.pin_hash):
_record_failed_attempt(client_ip)
raise HTTPException(status_code=401, detail="Invalid PIN")
# Clear failed attempts on successful login
_failed_attempts.pop(client_ip, None)
# Create session
token = create_session_token(settings_obj.id)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
_set_session_cookie(response, token)
return {"message": "Login successful", "authenticated": True}

View File

@ -132,9 +132,11 @@ async def get_upcoming(
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
# Get upcoming events
# Get upcoming events (from today onward)
today_start = datetime.combine(today, datetime.min.time())
events_query = select(CalendarEvent).where(
CalendarEvent.start_datetime <= cutoff_datetime
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
)
events_result = await db.execute(events_query)
events = events_result.scalars().all()

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime, timezone
from datetime import datetime
from app.database import get_db
from app.models.todo import Todo
@ -95,7 +95,7 @@ async def update_todo(
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now(timezone.utc)
update_data["completed_at"] = datetime.now()
elif not update_data["completed"]:
update_data["completed_at"] = None
@ -141,7 +141,7 @@ async def toggle_todo(
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
todo.completed_at = datetime.now(timezone.utc) if todo.completed else None
todo.completed_at = datetime.now() if todo.completed else None
await db.commit()
await db.refresh(todo)

View File

@ -1,10 +1,17 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime
class SettingsCreate(BaseModel):
pin: str
@field_validator('pin')
@classmethod
def pin_min_length(cls, v: str) -> str:
if len(v) < 4:
raise ValueError('PIN must be at least 4 characters')
return v
class SettingsUpdate(BaseModel):
accent_color: str | None = None
@ -24,3 +31,10 @@ class SettingsResponse(BaseModel):
class ChangePinRequest(BaseModel):
old_pin: str
new_pin: str
@field_validator('new_pin')
@classmethod
def new_pin_min_length(cls, v: str) -> str:
if len(v) < 4:
raise ValueError('New PIN must be at least 4 characters')
return v

View File

@ -1,8 +1,9 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@ -14,6 +15,11 @@ export default function LockScreen() {
const [pin, setPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
// Redirect authenticated users to dashboard
if (authStatus?.authenticated) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -30,15 +36,15 @@ export default function LockScreen() {
await setup(pin);
toast.success('PIN created successfully');
navigate('/dashboard');
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to create PIN');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to create PIN'));
}
} else {
try {
await login(pin);
navigate('/dashboard');
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Invalid PIN');
} catch (error) {
toast.error(getErrorMessage(error, 'Invalid PIN'));
setPin('');
}
}

View File

@ -155,7 +155,10 @@ export default function ProjectDetail() {
</Button>
<Button
variant="destructive"
onClick={() => deleteProjectMutation.mutate()}
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
Delete Project
@ -260,7 +263,10 @@ export default function ProjectDetail() {
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(task.id)}
onClick={() => {
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(task.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete task"
>
@ -321,7 +327,10 @@ export default function ProjectDetail() {
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(subtask.id)}
onClick={() => {
if (!window.confirm('Delete this subtask?')) return;
deleteTaskMutation.mutate(subtask.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>

View File

@ -27,7 +27,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
const [formData, setFormData] = useState({
title: reminder?.title || '',
description: reminder?.description || '',
remind_at: reminder?.remind_at || '',
remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '',
recurrence_rule: reminder?.recurrence_rule || '',
});

View File

@ -64,6 +64,15 @@ Personal life administration web app with dark theme, accent color customization
- [x] Frontend: `CalendarWidget` (today's events with color indicators)
- [x] Frontend: Active reminders section in dashboard
### Phase 6b: Project Subtasks
- [x] Backend: Self-referencing `parent_task_id` FK on `project_tasks` with CASCADE delete
- [x] Backend: Alembic migration `002_add_subtasks.py`
- [x] Backend: Schema updates — `parent_task_id` in create, nested `subtasks` in response, `model_rebuild()`
- [x] Backend: Chained `selectinload` for two-level subtask loading, parent validation on create
- [x] Frontend: `ProjectTask` type updated with `parent_task_id` and `subtasks`
- [x] Frontend: `TaskForm` accepts `parentTaskId` prop, context-aware dialog title
- [x] Frontend: `ProjectDetail` — expand/collapse chevrons, subtask progress bars, indented subtask cards
### Phase 7: Settings & Polish
- [x] Backend: Settings router (get/update settings, change PIN)
- [x] Frontend: `SettingsPage` (accent color picker, upcoming range, PIN change)
@ -119,6 +128,31 @@ These were found during first Docker build and integration testing:
---
## Code Review Findings (Senior Review)
### Critical:
- [x] C1: CORS `allow_origins=["*"]` with `allow_credentials=True` — already restricted to `["http://localhost:5173"]` (`main.py`)
- [x] C2: `datetime.now(timezone.utc)` in naive column — changed to `datetime.now()` (`todos.py`)
- [x] C3: Session cookie missing `secure` flag — added `secure=True` + `_set_session_cookie` helper (`auth.py`)
- [x] C4: No PIN length validation on backend — added `field_validator` for min 4 chars (`schemas/settings.py`)
### High:
- [x] H1: No brute-force protection on login — added in-memory rate limiting (5 attempts / 5 min) (`auth.py`)
- [x] H2: `echo=True` on SQLAlchemy engine — set to `False` (`database.py`)
- [x] H3: Double commit pattern — removed auto-commit from `get_db`, routers handle commits (`database.py`)
- [ ] H4: `Person.relationship` column shadows SQLAlchemy name — deferred (requires migration + schema changes across stack)
- [x] H5: Upcoming events missing lower bound filter — added `>= today_start` (`dashboard.py`)
- [x] H6: `ReminderForm.tsx` doesn't slice `remind_at` — added `.slice(0, 16)` for datetime-local input
### Medium:
- [x] M1: Default `SECRET_KEY` is predictable — added stderr warning on startup (`config.py`)
- [x] M3: `create_all` in lifespan conflicts with Alembic — removed (`main.py`)
- [x] M6: No confirmation dialog before destructive actions — added `window.confirm()` on all delete buttons
- [x] M7: Authenticated users can still navigate to `/login` — added `Navigate` redirect in `LockScreen.tsx`
- [x] L1: Error handling in LockScreen used `error: any` — replaced with `getErrorMessage` helper
---
## Outstanding Items (Resume Here If Halted)
### Critical (blocks deployment):
@ -152,7 +186,8 @@ backend/
│ ├── env.py
│ ├── script.py.mako
│ └── versions/
│ └── 001_initial_migration.py
│ ├── 001_initial_migration.py
│ └── 002_add_subtasks.py
└── app/
├── main.py
├── config.py