Files
StarPunk/starpunk/routes/auth.py
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

182 lines
4.9 KiB
Python

"""
Authentication routes for StarPunk
Handles IndieLogin authentication flow including login form, OAuth callback,
and logout functionality.
"""
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
url_for,
)
from starpunk.auth import (
IndieLoginError,
InvalidStateError,
UnauthorizedError,
destroy_session,
handle_callback,
initiate_login,
require_auth,
verify_session,
)
# Create blueprint
bp = Blueprint("auth", __name__, url_prefix="/admin")
@bp.route("/login", methods=["GET"])
def login_form():
"""
Display login form
If user is already authenticated, redirects to admin dashboard.
Otherwise shows login form for IndieLogin authentication.
Returns:
Redirect to dashboard if authenticated, otherwise login template
Template: templates/admin/login.html
"""
# Check if already logged in
session_token = request.cookies.get("starpunk_session")
if session_token and verify_session(session_token):
return redirect(url_for("admin.dashboard"))
return render_template("admin/login.html")
@bp.route("/login", methods=["POST"])
def login_initiate():
"""
Initiate IndieLogin authentication flow
Validates the submitted 'me' URL and redirects to IndieLogin.com
for authentication.
Form data:
me: User's personal website URL
Returns:
Redirect to IndieLogin.com or back to login form on error
Raises:
Flashes error message and redirects on validation failure
"""
me_url = request.form.get("me", "").strip()
if not me_url:
flash("Please enter your website URL", "error")
return redirect(url_for("auth.login_form"))
try:
# Initiate IndieLogin flow
auth_url = initiate_login(me_url)
return redirect(auth_url)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("auth.login_form"))
@bp.route("/callback")
def callback():
"""
Handle IndieLogin callback
Processes the OAuth callback from IndieLogin.com, validates the
authorization code and state token, and creates an authenticated session.
Query parameters:
code: Authorization code from IndieLogin
state: CSRF state token
Returns:
Redirect to admin dashboard on success, login form on failure
Sets:
session cookie (HttpOnly, Secure, SameSite=Lax, 30 day expiry)
"""
code = request.args.get("code")
state = request.args.get("state")
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
try:
# Handle callback and create session
session_token = handle_callback(code, state)
# Create response with redirect
response = redirect(url_for("admin.dashboard"))
# Set secure session cookie
secure = current_app.config.get("SITE_URL", "").startswith("https://")
response.set_cookie(
"starpunk_session",
session_token,
httponly=True,
secure=secure,
samesite="Lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
flash("Login successful!", "success")
return response
except InvalidStateError as e:
current_app.logger.error(f"Invalid state error: {e}")
flash("Authentication failed: Invalid state token (possible CSRF)", "error")
return redirect(url_for("auth.login_form"))
except UnauthorizedError as e:
current_app.logger.error(f"Unauthorized: {e}")
flash("Authentication failed: Not authorized as admin", "error")
return redirect(url_for("auth.login_form"))
except IndieLoginError as e:
current_app.logger.error(f"IndieLogin error: {e}")
flash(f"Authentication failed: {e}", "error")
return redirect(url_for("auth.login_form"))
except Exception as e:
current_app.logger.error(f"Unexpected auth error: {e}")
flash("Authentication failed: An unexpected error occurred", "error")
return redirect(url_for("auth.login_form"))
@bp.route("/logout", methods=["POST"])
@require_auth
def logout():
"""
Logout and destroy session
Destroys the user's session and clears the session cookie.
Requires authentication (user must be logged in to logout).
Returns:
Redirect to homepage with session cookie cleared
Decorator: @require_auth
"""
session_token = request.cookies.get("starpunk_session")
# Destroy session in database
if session_token:
try:
destroy_session(session_token)
except Exception as e:
current_app.logger.error(f"Error destroying session: {e}")
# Clear cookie and redirect
response = redirect(url_for("public.index"))
response.delete_cookie("starpunk_session")
flash("Logged out successfully", "success")
return response