Build the core user-facing experience with admin login (bcrypt + signed session cookies), profile switcher, workout day viewer with warmups and exercise cards, and HTMX-powered exercise browser with search/filter. - AuthService with bcrypt password verification and itsdangerous session tokens - Auth dependency redirects to /login (303) for unauthenticated requests - NavContextMiddleware injects admin/profiles/active_profile into all templates - Profile management (list, switch, edit) with cookie-based active profile - Workout day viewer shows warmups + exercises + per-user programming targets - Exercise browser with HTMX filter dropdowns (no page reloads) - Flash message partial for success/error feedback - 12 new tests (66 total passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
100 lines
2.7 KiB
Python
100 lines
2.7 KiB
Python
"""Authentication routes for admin login and logout.
|
|
|
|
Handles the login form, credential verification, session cookie
|
|
management, and logout.
|
|
"""
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from sqlmodel import Session
|
|
|
|
from app.database import get_db_session
|
|
from app.services.auth_service import AuthService
|
|
from app.utils.auth import SESSION_COOKIE_NAME
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter(tags=["auth"])
|
|
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
"""Render the login form.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
|
|
Returns:
|
|
Rendered login page HTML.
|
|
"""
|
|
templates = request.app.state.templates
|
|
return templates.TemplateResponse("pages/login.html", {
|
|
"request": request,
|
|
"error": None,
|
|
})
|
|
|
|
|
|
@router.post("/login")
|
|
async def login_submit(
|
|
request: Request,
|
|
session: Session = Depends(get_db_session),
|
|
):
|
|
"""Process login form submission.
|
|
|
|
Verifies credentials and sets a session cookie on success.
|
|
Re-renders the login page with an error on failure.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Redirect to home on success, or login page with error.
|
|
"""
|
|
form = await request.form()
|
|
username = form.get("username", "")
|
|
password = form.get("password", "")
|
|
|
|
secret_key = request.app.state.secret_key
|
|
auth_service = AuthService(session, secret_key=secret_key)
|
|
user = auth_service.authenticate(username, password)
|
|
|
|
if user is None:
|
|
templates = request.app.state.templates
|
|
return templates.TemplateResponse("pages/login.html", {
|
|
"request": request,
|
|
"error": "Invalid username or password.",
|
|
}, status_code=200)
|
|
|
|
# Create session token and set cookie — httponly and samesite for security
|
|
token = auth_service.create_session_token(user_id=user.id)
|
|
response = RedirectResponse(url="/", status_code=303)
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
max_age=86400, # 24 hours
|
|
)
|
|
|
|
logger.info("login_success", username=username)
|
|
return response
|
|
|
|
|
|
@router.get("/logout")
|
|
async def logout(request: Request):
|
|
"""Clear the session cookie and redirect to login.
|
|
|
|
Args:
|
|
request: The incoming HTTP request.
|
|
|
|
Returns:
|
|
Redirect to login page.
|
|
"""
|
|
response = RedirectResponse(url="/login", status_code=303)
|
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
|
response.delete_cookie(key="active_profile_id")
|
|
logger.info("logout")
|
|
return response
|