diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 59f0e68..b815847 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,3 +1,5 @@ +import hmac + import yaml from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel @@ -28,7 +30,11 @@ def _load_credentials() -> tuple[str, str]: @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest) -> TokenResponse: username, password_hash = _load_credentials() - if body.username != username or not verify_password(body.password, password_hash): + # Always run both checks to prevent timing-based username enumeration. + # hmac.compare_digest is constant-time; verify_password (bcrypt) always runs. + username_ok = hmac.compare_digest(body.username, username) + password_ok = verify_password(body.password, password_hash) + if not username_ok or not password_ok: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") token = create_access_token(body.username) return TokenResponse(access_token=token) diff --git a/docker-compose.yml b/docker-compose.yml index 6cf7573..22598c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: Dockerfile.backend restart: unless-stopped environment: - SECRET_KEY: ${SECRET_KEY:-change_me_in_production} + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file} SQLITE_PATH: /app/data/homelab.db CONFIG_PATH: /app/config.yml CORS_ORIGINS: '["http://localhost:3000"]' diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 84933d1..889ff88 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { persist } from 'zustand/middleware' +import { persist, createJSONStorage } from 'zustand/middleware' interface AuthState { token: string | null @@ -16,6 +16,11 @@ export const useAuthStore = create()( login: (token) => set({ token, isAuthenticated: true }), logout: () => set({ token: null, isAuthenticated: false }), }), - { name: 'homelable-auth' } + { + name: 'homelable-auth', + // sessionStorage: scoped to the tab, cleared on browser close. + // Prevents XSS from other tabs stealing the token via localStorage. + storage: createJSONStorage(() => sessionStorage), + } ) )