Public Access
feat: remove login requirements for internal home-network use
- backend/app/security.py: require_session() now auto-authenticates by returning the first family_profile_id from the DB. No cookie or password needed. Falls back to "bootstrap" sentinel if no FamilyProfile exists. Admin routes (require_admin) still protected by bearer token. - frontend/src/api/index.ts: removed 401→/login redirect interceptor - frontend/src/App.tsx: removed Sign out button, removed /login route and Login page import - Login page kept on disk (unused) for potential future re-enablement
This commit is contained in:
+30
-22
@@ -4,18 +4,21 @@ Auth dependencies for the MealPlanner backend.
|
|||||||
Two flavors:
|
Two flavors:
|
||||||
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
|
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
|
||||||
to ``settings.ADMIN_TOKEN`` (must be set in env).
|
to ``settings.ADMIN_TOKEN`` (must be set in env).
|
||||||
- ``require_session`` — signed-cookie session (``itsdangerous``) gating
|
- ``require_session`` — auto-returns the first family_profile_id (no login
|
||||||
mutations on the family-facing routers; reads stay open inside the
|
required). This app runs on a private home network so auth is disabled
|
||||||
trusted network.
|
for family-facing routes. Kept as a dependency so admin/token endpoints
|
||||||
|
can be re-enabled later by restoring cookie logic.
|
||||||
|
|
||||||
The per-voter approval-token flow on meal items is intentionally NOT covered
|
The per-voter approval-token flow on meal items is intentionally NOT covered
|
||||||
here — it has its own short-lived single-use tokens elsewhere.
|
here — it has its own short-lived single-use tokens elsewhere.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
from itsdangerous import TimestampSigner
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import FamilyProfile
|
||||||
|
|
||||||
bearer_header = "Authorization"
|
bearer_header = "Authorization"
|
||||||
|
|
||||||
@@ -49,22 +52,27 @@ def issue_session(family_profile_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def require_session(request: Request) -> str:
|
def require_session(request: Request) -> str:
|
||||||
"""Return the family_profile_id stored in the signed session cookie."""
|
"""Auto-authenticate: return the first family_profile_id from the DB.
|
||||||
|
|
||||||
|
No cookie or password needed — this app runs on a private home network.
|
||||||
|
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
|
||||||
|
initialise itself on first run.
|
||||||
|
"""
|
||||||
|
# 1. Try to read the signed cookie (backward-compat with existing sessions)
|
||||||
raw = request.cookies.get(SESSION_COOKIE)
|
raw = request.cookies.get(SESSION_COOKIE)
|
||||||
if not raw:
|
if raw:
|
||||||
raise HTTPException(
|
try:
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Session required"
|
return (
|
||||||
)
|
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
|
||||||
try:
|
)
|
||||||
family_id = (
|
except Exception:
|
||||||
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
|
pass # fall through to auto-auth
|
||||||
)
|
|
||||||
except SignatureExpired:
|
# 2. Auto-auth: grab the first family profile from the DB
|
||||||
raise HTTPException(
|
db = next(get_db())
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired"
|
profile = db.query(FamilyProfile).first()
|
||||||
)
|
if profile:
|
||||||
except BadSignature:
|
return str(profile.id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session"
|
# 3. Bootstrap hatch — no profile yet, return a sentinel value
|
||||||
)
|
return "bootstrap"
|
||||||
return family_id
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import Dashboard from './pages/Dashboard'
|
|||||||
import MealDetail from './pages/MealDetail'
|
import MealDetail from './pages/MealDetail'
|
||||||
import Pantry from './pages/Pantry'
|
import Pantry from './pages/Pantry'
|
||||||
import ShoppingList from './pages/ShoppingList'
|
import ShoppingList from './pages/ShoppingList'
|
||||||
import Login from './pages/Login'
|
|
||||||
import { mealPlannerApi } from './api'
|
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@@ -30,17 +28,6 @@ function Navigation() {
|
|||||||
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
||||||
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
await mealPlannerApi.auth.logout().catch(() => {})
|
|
||||||
window.location.href = '/login'
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium text-surface-500 hover:text-surface-900 px-3 py-2 rounded-lg hover:bg-surface-100 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,7 +47,6 @@ function App() {
|
|||||||
<Route path="/meals/:id" element={<MealDetail />} />
|
<Route path="/meals/:id" element={<MealDetail />} />
|
||||||
<Route path="/pantry" element={<Pantry />} />
|
<Route path="/pantry" element={<Pantry />} />
|
||||||
<Route path="/shopping-list" element={<ShoppingList />} />
|
<Route path="/shopping-list" element={<ShoppingList />} />
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,16 +10,6 @@ const api = axios.create({
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.response.use(
|
|
||||||
response => response,
|
|
||||||
error => {
|
|
||||||
if (error.response?.status === 401 && window.location.pathname !== '/login') {
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const mealPlannerApi = {
|
export const mealPlannerApi = {
|
||||||
auth: {
|
auth: {
|
||||||
login: (password: string) => api.post('/auth/login', { password }),
|
login: (password: string) => api.post('/auth/login', { password }),
|
||||||
|
|||||||
Reference in New Issue
Block a user