Bliki Phase 3: Authentication & ACL

Phase 3 added user authentication and fine-grained access control, transforming Bliki from a personal publishing tool into a collaborative platform where content can be protected and selectively shared.

The Problem

Previously, all content was public. There was no way to:

The solution needed to be lightweight — no external auth providers, no database, no session store. Just files, tokens, and role-based access control.

JWT-Based Authentication

Authentication uses JWT (JSON Web Tokens) with HS256 signing:

The token is issued on login and validated on each protected request. The POST /api/login endpoint accepts {username, password}, validates credentials against users.json, and returns a JWT cookie.

Security decision: jwt_secret is required if users exist. The server refuses to start with users in users.json but no jwt_secret configured. This prevents accidental token forgery with an empty secret.

User Storage

Users are stored in users.json in the same directory as bliki.toml:

{
  "users": {
    "admin": { "hash": "$2a$12$...", "role": "admin" },
    "alice": { "hash": "$2a$12$...", "role": "editor" },
    "bob": { "hash": "$2a$12$...", "role": "viewer" }
  }
}

bcrypt hashing — passwords are hashed with golang.org/x/crypto/bcrypt at cost 12. The hash is generated on user creation and never stored in plaintext.

Atomic writesusers.json uses a .tmp → rename pattern to prevent corruption from concurrent writes.

Validation rules:

User Management CLI

bliki new user --name admin --password "secret" --role admin

The new user command creates or updates a user:

Three roles define the access hierarchy:

Role Read Write
admin everything everything
editor paths with editor/viewer rules paths with editor rules
viewer paths with viewer rules nothing

Each level inherits permissions below it (viewer < editor < admin).

ACL System

Access Control Lists are stored in acl.json in the same directory as bliki.toml:

{
  "rules": [
    { "path": "blog/private.md", "role": "admin" },
    { "path": "admin/*", "role": "editor" },
    { "path": "internal/*", "role": "viewer" }
  ]
}

Matching logic:

  1. Admin users bypass all ACL checks (full access to everything)
  2. Non-admins find the longest matching pattern using path.Match (Go standard library glob)
  3. No matching rule → page is public
  4. Matching rule → check user role against required role

Longest-match-wins means specific patterns override broad ones:

Rule: "blog/*"          → matches "blog/page.md" (role: viewer)
Rule: "blog/private.md" → matches "blog/private.md" (role: admin)

→ "blog/private.md" gets role "admin" (longer pattern wins)
→ "blog/other.md" gets role "viewer" (only the broader pattern matches)

Backward Compatibility

The authentication system is backward compatible:

Template Data

Authenticated users receive three new fields in template data:

These enable conditional rendering in layouts:

{{ if .Authenticated }}
    Welcome, {{ .Username }}!
{{ else }}
    <a href="/login">Login</a>
{{ end }}

Frontend Components

Two web components handle auth state in the browser:

<bliki-logged-user> — toggles between login/logout links based on GET /api/user response. When authenticated, shows the username; when not, shows the login link.

<bliki-login> — form component with username/password inputs. Posts to /api/login, redirects on success, displays errors in an [error] element.

Endpoint Design

Endpoint Method Auth Required Purpose
/api/login POST No Issues JWT (accepts username/password)
/api/logout POST No Clears JWT session
/api/user GET No Returns { authenticated, username }
/render/<path> GET Yes Manual JWT validation + ACL check
/api/render GET Yes JSON content with template context
/api/save POST Yes Web editor save (JWT + ACL check)
/api/lock GET Yes Lock status for a path

Protected pages use the /render/ endpoint which performs on-the-fly rendering with JWT validation and ACL checks. The /api/render endpoint serves the same content via JSON, populating .Pages and .Current for full template function availability.

Design Evolution

The auth system went through several iterations:

  1. Cookie-only auth → migrated to JWT for stateless validation
  2. Simple role check → evolved into ACL with longest-match-wins pattern matching
  3. Hardcoded paths → moved to config-driven acl.json
  4. Session store → eliminated entirely; JWT is self-contained with no server-side session

What's Next

Phase 3 delivered a complete authentication and access control system with JWT tokens, bcrypt passwords, role-based ACLs, and backward compatibility. Phase 3.5 built on this foundation: the inline web editor uses authentication to gate editing access, and SSE rebuild notifications are only sent to authenticated clients.