diff --git a/backend/app/config.py b/backend/app/config.py index 6412bf8..f00a2ec 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,6 +5,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra" SECRET_KEY: str = "your-secret-key-change-in-production" + ENVIRONMENT: str = "development" + COOKIE_SECURE: bool = False model_config = SettingsConfigDict( env_file=".env", @@ -16,7 +18,15 @@ 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, - ) + if settings.ENVIRONMENT != "development": + print( + "FATAL: Default SECRET_KEY detected in non-development environment. " + "Set a unique SECRET_KEY in .env immediately.", + file=sys.stderr, + ) + sys.exit(1) + else: + print( + "WARNING: Using default SECRET_KEY. Set SECRET_KEY in .env for production.", + file=sys.stderr, + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 38871eb..ac4213a 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,6 +22,9 @@ _failed_attempts: dict[str, list[float]] = defaultdict(list) _MAX_ATTEMPTS = 5 _WINDOW_SECONDS = 300 # 5-minute lockout window +# Server-side session revocation (in-memory, sufficient for single-user app) +_revoked_sessions: set[str] = set() + def _check_rate_limit(ip: str) -> None: """Raise 429 if IP has exceeded failed login attempts.""" @@ -29,7 +32,10 @@ def _check_rate_limit(ip: str) -> None: 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: + # Remove the key entirely if no recent attempts remain + if not _failed_attempts[ip]: + del _failed_attempts[ip] + elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS: raise HTTPException( status_code=429, detail="Too many failed login attempts. Try again in a few minutes.", @@ -71,7 +77,7 @@ def _set_session_cookie(response: Response, token: str) -> None: key="session", value=token, httponly=True, - secure=True, + secure=app_settings.COOKIE_SECURE, max_age=86400 * 30, # 30 days samesite="lax", ) @@ -85,6 +91,10 @@ async def get_current_session( if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") + # Check if session has been revoked + if session_cookie in _revoked_sessions: + raise HTTPException(status_code=401, detail="Session has been revoked") + user_id = verify_session_token(session_cookie) if user_id is None: raise HTTPException(status_code=401, detail="Invalid or expired session") @@ -105,7 +115,7 @@ async def setup_pin( db: AsyncSession = Depends(get_db) ): """Create initial PIN. Only works if no settings exist.""" - result = await db.execute(select(Settings)) + result = await db.execute(select(Settings).with_for_update()) existing = result.scalar_one_or_none() if existing: @@ -156,9 +166,19 @@ async def login( @router.post("/logout") -async def logout(response: Response): - """Clear session cookie.""" - response.delete_cookie(key="session") +async def logout( + response: Response, + session_cookie: Optional[str] = Cookie(None, alias="session") +): + """Clear session cookie and invalidate server-side session.""" + if session_cookie: + _revoked_sessions.add(session_cookie) + response.delete_cookie( + key="session", + httponly=True, + secure=app_settings.COOKIE_SECURE, + samesite="lax" + ) return {"message": "Logout successful"} @@ -175,8 +195,11 @@ async def auth_status( authenticated = False if not setup_required and session_cookie: - user_id = verify_session_token(session_cookie) - authenticated = user_id is not None + if session_cookie in _revoked_sessions: + authenticated = False + else: + user_id = verify_session_token(session_cookie) + authenticated = user_id is not None return { "authenticated": authenticated, diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 5b579e9..1eaf69a 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -24,10 +24,10 @@ async def get_events( query = select(CalendarEvent) if start: - query = query.where(CalendarEvent.start_datetime >= start) + query = query.where(CalendarEvent.end_datetime >= start) if end: - query = query.where(CalendarEvent.end_datetime <= end) + query = query.where(CalendarEvent.start_datetime <= end) query = query.order_by(CalendarEvent.start_datetime.asc()) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 7f220c5..61d6b49 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,13 +1,15 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime, date -from typing import Optional, List +from typing import Optional, List, Literal from app.schemas.project_task import ProjectTaskResponse +ProjectStatus = Literal["not_started", "in_progress", "completed"] + class ProjectCreate(BaseModel): name: str description: Optional[str] = None - status: str = "not_started" + status: ProjectStatus = "not_started" color: Optional[str] = None due_date: Optional[date] = None @@ -15,7 +17,7 @@ class ProjectCreate(BaseModel): class ProjectUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None - status: Optional[str] = None + status: Optional[ProjectStatus] = None color: Optional[str] = None due_date: Optional[date] = None diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 50737e2..2696c4f 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -1,13 +1,16 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime, date -from typing import Optional, List +from typing import Optional, List, Literal + +TaskStatus = Literal["pending", "in_progress", "completed"] +TaskPriority = Literal["low", "medium", "high"] class ProjectTaskCreate(BaseModel): title: str description: Optional[str] = None - status: str = "pending" - priority: str = "medium" + status: TaskStatus = "pending" + priority: TaskPriority = "medium" due_date: Optional[date] = None person_id: Optional[int] = None sort_order: int = 0 @@ -17,8 +20,8 @@ class ProjectTaskCreate(BaseModel): class ProjectTaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None - status: Optional[str] = None - priority: Optional[str] = None + status: Optional[TaskStatus] = None + priority: Optional[TaskPriority] = None due_date: Optional[date] = None person_id: Optional[int] = None sort_order: Optional[int] = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index b5cd284..a94fa8b 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -1,5 +1,16 @@ from pydantic import BaseModel, ConfigDict, field_validator from datetime import datetime +from typing import Literal, Optional + +AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"] + + +def _validate_pin_length(v: str, label: str = "PIN") -> str: + if len(v) < 4: + raise ValueError(f'{label} must be at least 4 characters') + if len(v) > 72: + raise ValueError(f'{label} must be at most 72 characters') + return v class SettingsCreate(BaseModel): @@ -7,14 +18,12 @@ class SettingsCreate(BaseModel): @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 + def pin_length(cls, v: str) -> str: + return _validate_pin_length(v) class SettingsUpdate(BaseModel): - accent_color: str | None = None + accent_color: Optional[AccentColor] = None upcoming_days: int | None = None @@ -34,7 +43,5 @@ class ChangePinRequest(BaseModel): @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 + def new_pin_length(cls, v: str) -> str: + return _validate_pin_length(v, "New PIN") diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py index 4b0235f..9748811 100644 --- a/backend/app/schemas/todo.py +++ b/backend/app/schemas/todo.py @@ -1,12 +1,14 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime, date -from typing import Optional +from typing import Optional, Literal + +TodoPriority = Literal["low", "medium", "high"] class TodoCreate(BaseModel): title: str description: Optional[str] = None - priority: str = "medium" + priority: TodoPriority = "medium" due_date: Optional[date] = None category: Optional[str] = None recurrence_rule: Optional[str] = None @@ -16,7 +18,7 @@ class TodoCreate(BaseModel): class TodoUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None - priority: Optional[str] = None + priority: Optional[TodoPriority] = None due_date: Optional[date] = None completed: Optional[bool] = None category: Optional[str] = None diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 71df70b..0085d07 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -32,10 +32,15 @@ server { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always; } # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always; } diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 028ff40..5715bff 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -17,7 +17,8 @@ export default function DashboardPage() { const { data, isLoading } = useQuery({ queryKey: ['dashboard'], queryFn: async () => { - const today = new Date().toISOString().split('T')[0]; + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const { data } = await api.get(`/dashboard?client_date=${today}`); return data; }, diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index ee1eeff..f459970 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -93,7 +93,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) { setFormData({ ...formData, status: e.target.value as any })} + onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })} > @@ -122,7 +122,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas