feat: add browse frontend auth with is_public filtering #27
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/pal-e-api!27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "26-browse-auth"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
is_public=False) are hidden from unauthenticated visitors/browse/frontend gets authChanges
models.py: AddUsermodel withid,email,hashed_password,is_approved,created_atauth.py(new): bcrypt password hashing,authenticate_user(),get_current_user()session cookie readerconfig.py: AddPALDOCS_SECRET_KEY,PALDOCS_SEED_EMAIL,PALDOCS_SEED_PASSWORDsettingsmain.py: AddSessionMiddleware, seed user on startup via lifespanroutes/frontend.py: Add/browse/login(GET/POST),/browse/logout(POST); addis_publicfiltering to all browse queries; passusercontext to all templatestemplates/login.html(new): Email + password login form styled to match existing CSStemplates/base.html: Add Login/Logout button to nav bar, add.badge-privateCSStemplates/landing.html,project_notes.html,tag_notes.html,note.html: Add private badge on notesalembic/versions/b2c3d4e5f6a7_add_users_table.py(new): Migration for users tablealembic/env.py: ImportUsermodelpyproject.toml: Addbcrypt>=4.0,itsdangerous>=2.0,python-multipart>=0.0.6tests/test_auth.py(new): 14 tests for auth flow and filteringtests/conftest.py: ImportUsermodelTest Plan
ruff checkandruff format --checkpassReview Checklist
Related Notes
plan-2026-02-25-private-notes-auth-- Phase 1 implementationReview round 1 fix:
clientparameter from_create_user()test helper_get_test_db()helper to reduce boilerplate in authenticated test functions (was repeating the same 4-line pattern in 7 tests)All 28 tests pass, ruff check and format clean.
QA Review — PR #27: Browse Frontend Auth with is_public Filtering
Verdict: PASS (with 1 minor security note and 2 suggestions)
No blockers found. The implementation is solid, well-tested, and follows existing codebase patterns.
Test Results
ruff check .— all checks passed)Security Findings
No blockers. Detailed analysis:
/browse/notes/{private-slug}frontend.py)_apply_public_filter()filters query (line 69)gensalt(), no plaintext storagehttponlySessionMiddlewarealways setshttponlysamesitelaxitsdangerous.TimestampSignerwith configurablesecret_keycheckpwis constant-timeMinor note (non-blocker): The
https_onlyparameter onSessionMiddlewaredefaults toFalse, meaning the session cookie lacks theSecureflag. Since this runs behind Tailscale (not public internet) and uses HTTPS via the Tailscale cert, this is acceptable for Phase 1. Consider settinghttps_only=Truein a future phase if the service ever faces the public internet.Minor note (non-blocker): Linked notes on the
/browse/notes/{slug}page (lines 132-145 offrontend.py) do NOT filter byis_public. If a public note links to a private note, an unauthenticated visitor will see the private note's title and slug in the "Linked Notes" section (though clicking it returns 404). This is a minor information leak. Consider filtering linked notes byis_publicfor unauthenticated users in a follow-up.Bugs Found
None.
Code Quality
auth.pyhandles password/session logic,frontend.pyhandles routes_apply_public_filter()helper is DRY and applied consistently to all listing queriesupgrade()anddowngrade()with unique index on emailserver_default=sa.text('1')foris_approved)hashed_passwordcolumn isString(128)— bcrypt hashes are 60 chars, plenty of room_seed_user()function correctly skips if user already exists (idempotent)Suggestions (nice-to-haves, not blockers)
Linked notes leak: Filter the outgoing/incoming note link queries by
is_publicwhen user isNone. Low priority since titles are not sensitive in this context, but worth a follow-up.https_only=True: Consider addingSessionMiddleware(..., https_only=True)since the service runs over HTTPS via Tailscale. This adds theSecurecookie flag as defense-in-depth.TemplateResponse deprecation warnings: 11 test warnings about
TemplateResponse(name, {"request": request})vs the newTemplateResponse(request, name)API. Not introduced by this PR (pre-existing), but worth noting for a cleanup pass.Summary
Clean implementation. Auth flow works correctly (login, logout, session cookies). Private notes are properly hidden from unauthenticated users across all browse pages. API routes remain unaffected. Tests cover all the critical paths. Good to merge.
QA Review — PR #27 (Updated)
Verdict: PASS with one non-blocking finding
1. Security Findings
FIXED — Previously reported issues confirmed resolved:
https_only=Trueon SessionMiddleware — Confirmed onmain.py:49. Thesecureflag is now set on session cookies.frontend.py:132-137correctly redirects unauthenticated users to/browse/loginwithmessageandnextparameters instead of returning a 404.frontend.py:150-152correctly filters both outgoing and incoming links byis_publicfor unauthenticated users.Session cookie security — PASS:
SessionMiddlewarealways setshttponlyin security flags (verified in source)https_only=True, thesecureflag is also setsame_site=lax(Starlette default) — appropriateitsdangerous.TimestampSignerwith the configuredsecret_keyuser_email), not raw credentialsPassword hashing — PASS:
auth.py:9usesbcrypt.hashpwwithbcrypt.gensalt()— correctauth.py:13usesbcrypt.checkpwfor verification — correctAuth bypass vectors — PASS:
get_current_user()queries the DB each request (no stale session data)authenticate_user()checksis_approvedflag — unapproved users can't loginbrowse_note), not just hidden from listingslanding,projects/{slug},tags/{name}) consistently apply_apply_public_filter()/notes,/notes/{slug}) remains unprotected — this is intentional and documented (API auth tracked in issue #2)Open redirect on
nextparameter — LOW SEVERITY (non-blocking):frontend.py:41,54:An attacker could craft
?next=https://evil.comor?next=//evil.comto redirect a user to a malicious site after login. This is a classic open redirect.Mitigation factors that make this non-blocking:
Suggested fix for a future PR: Validate that
next_urlstarts with/and does not start with//:2. Bugs Found
None.
3. Login Redirect UX — PASS
/browse/login?message=Log+in+to+view+private+notes&next=/browse/notes/{slug}✓messagequery param in a styled info box ✓/browse/login?next=...readsnextfrom query params and redirects after successful auth ✓login.htmlform action preserves the query string:action="/browse/login{{ '?' + request.query_string.decode() if request.query_string else '' }}"✓4. Linked Notes Filtering — PASS
frontend.py:140-152: Outgoing links (NoteLink.source_id == note.id) filtered byNote.is_publicfor unauthenticated ✓frontend.py:146-152: Incoming links (NoteLink.target_id == note.id) filtered byNote.is_publicfor unauthenticated ✓user is not None) see all links without filtering ✓test_unauthenticated_linked_notes_hides_private,test_authenticated_linked_notes_shows_all,test_unauthenticated_incoming_links_hides_private) ✓5. Test Results — PASS
All 33 tests passed (19 auth + 11 CRUD + 2 health + 1 revision):
Linting:
ruff checkandruff format --checkboth clean.Test coverage assessment:
The 19 auth tests cover:
is_publicfiltering on landing, tag pages, project pagesnextparam redirectMinor gap (non-blocking):
nextparameter (matches the finding above)is_approved=Falseuser being rejected (the code handles it inauth.py:22, but no test exercises it)6. Suggestions (non-blocking)
nextparameter (described above) — track as a separate issueis_approved=Falserejection — small gap in auth test coveragesecret_keydefault —config.py:8hassecret_key: str = "change-me-in-production". This is fine for dev, but ensure the k8s deployment setsPALDOCS_SECRET_KEYvia a secret. (Not in scope for this PR.)TemplateResponse(name, {"request": request})being deprecated in favor ofTemplateResponse(request, name). Not blocking, but worth a cleanup PR.Overall: APPROVED — The two previously reported findings are correctly fixed. The open redirect is low-severity given Tailscale-only access and can be addressed in a follow-up.
QA Review Pass 3 — APPROVED
Verdict: PASS
Security Checklist
_apply_public_filter()applied to landing query; test confirms_apply_public_filter()applied inbrowse_project_notes; test confirms_apply_public_filter()applied inbrowse_tag_notes; test confirms/browse/notes/{private-slug}gets redirected to loginfrontend.py; test confirms with redirect location validationnextparam, redirects to private notelogin_submitreadsnextfrom query params and redirects after auth; test confirmsnext=https://evil.com,next=//evil.com)/browse/. Also verified/%2F/evil.comedge case is safe (browser treats as same-origin path).outgoing_qandincoming_qfiltered withNote.is_public.is_(True)when user is None; tests confirmauth.pyusesbcrypt.hashpwwithgensalt()SessionMiddlewarewithhttps_only=True. Starlette usesitsdangeroussigning and setshttponlyby default. Test client usesbase_url="https://testserver"to match.is_approved=Falseusers cannot log inauth.py; testtest_login_unapproved_user_rejectedconfirms 401 responsePALDOCS_SECRET_KEYnot hardcoded in productiondeployment.yamlreferencessecretKeyRef: pal-e-docs-secrets / secret-key. Config default"change-me-in-production"is fine for dev.{{ message }}in login.html is auto-escaped (confirmed<script>in query param renders as<script>). Only `TemplateResponse(request, template, context=...)signature.Test Results
Test Coverage Assessment
23 auth-specific tests covering: login flow (valid, bad creds, nonexistent user, unapproved user), logout, public/private filtering on landing/tag/project pages, private note redirect to login with message, login redirect with
nextparam, linked notes filtering (outgoing + incoming), open redirect prevention (absolute URL, protocol-relative), private badge rendering.Code Quality
auth.pyfor auth logic,_apply_public_filterhelper for consistent filtering54714bc57fbe->a1b2c3d4e5f6->b2c3d4e5f6a7Notes
/notes,/notes/{slug}) remains unprotected by design — auth scope is browse frontend onlyseed_email/seed_passwordconfig is clean for deployment bootstrappingNo bugs found. No blocking concerns.