Browse Source

feat(auth): add login and admin user management

main
Domagoj Zecevic 3 days ago
parent
commit
18d81da455
  1. 67
      .ai/HANDOFF.md
  2. 79
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 18
      README.md
  5. 19
      cmd/server/main.go
  6. 5
      go.mod
  7. 6
      go.sum
  8. 122
      internal/auth/handler.go
  9. 192
      internal/auth/handler_test.go
  10. 50
      internal/auth/middleware.go
  11. 208
      internal/auth/store.go
  12. 101
      internal/auth/store_test.go
  13. 30
      internal/db/db.go
  14. 30
      internal/web/router.go
  15. 32
      internal/web/router_test.go
  16. 39
      internal/web/templates/admin_users.html
  17. 25
      internal/web/templates/login.html
  18. 13
      internal/web/templates/templates.go

67
.ai/HANDOFF.md

@ -4,6 +4,34 @@ Append-only role handoff log. Each role adds one entry when its step is complete
---
### T-002 — review — 2026-06-18T10:50:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Rework pass review for T-002: blocking finding resolved — templates are now file-backed via embed.FS in `internal/web/templates/`; all tests pass; no new findings. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass. |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | implement |
---
### T-002 — review — 2026-06-18T10:35:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-002 auth; all tests pass but templates are inline Go strings instead of the file-based HTML required by the plan — blocking T-004 base template integration. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass; all 9 auth+web tests PASS. |
| Verdict | FAIL |
| Blocking Findings | 1. Templates are inline Go strings in handler.go instead of separate files in `internal/web/templates/` — must be moved to file-based templates before T-004 base template inheritance is possible. |
| Next Role | implement |
---
### T-001 — implement — 2026-06-18T08:20:22Z
| Field | Value |
@ -56,3 +84,42 @@ Append-only role handoff log. Each role adds one entry when its step is complete
| Next Role | implement |
---
### T-002 — implement — 2026-06-18T08:31:56Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added SQLite-backed authentication with bcrypt login/logout, session-cookie middleware, first-run admin bootstrap, and admin user management. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, cmd/server/main.go, go.mod, go.sum, internal/auth/handler.go, internal/auth/handler_test.go, internal/auth/middleware.go, internal/auth/store.go, internal/auth/store_test.go, internal/db/db.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/admin_users.html, internal/web/templates/login.html |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `go test ./internal/auth/... ./internal/web/...` passed |
| Commit | `feat(auth): add login and admin user management` |
| Next Role | review |
---
### T-002 — implement — 2026-06-18T08:42:00Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Addressed review finding by moving auth page rendering to file-backed templates exposed through an embedded template registry. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, internal/auth/handler.go, internal/auth/handler_test.go, internal/web/templates/admin_users.html, internal/web/templates/login.html, internal/web/templates/templates.go |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `go test ./internal/auth/...` passed |
| Commit | `feat(auth): add login and admin user management` |
| Next Role | review |
---
### T-002 — implement — 2026-06-18T08:52:43Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-002 auth after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(auth): add login and admin user management` |
| Next Role | none |
---

79
.ai/REVIEW.md

@ -21,6 +21,52 @@ Append-only reviewer log. Each reviewed task gets one section.
None — all findings are nits. No blocking issues.
---
## T-002 — Auth
**Verdict:** FAIL
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | major | `internal/auth/handler.go` (lines 119–183) | Templates (`login`, `admin_users`) are inline Go template strings, not separate `.html` files. The plan's repository layout explicitly lists `internal/web/templates/login.html` and `internal/web/templates/admin_users.html`. T-004 introduces `base.html` with `{{block "title" .}}` / `{{block "content" .}}` template inheritance — the admin_users page must be able to extend that base. Inline strings in a different package make that impossible without a refactor spike inside T-004. | **yes** |
| 2 | minor | `internal/auth/handler.go` (lines 49–56) | Session cookie has no `Secure` flag. If the app is reverse-proxied over HTTPS (the expected production path), the cookie is transmitted over HTTP between the browser and the proxy for nothing, defeating `HttpOnly`. Acceptable for pure LAN-only deployment but should be documented or made configurable. | no |
| 3 | minor | `internal/auth/handler.go` (lines 104–116) | `DeleteUser` does not prevent deleting the last admin (or the currently logged-in user). An admin could inadvertently lock out all access. | no |
| 4 | nit | `go.mod` (line 3) | Go version bumped from `1.22` (specified in the plan) to `1.25.0`. Higher is generally fine but is an undocumented deviation from the plan's stated constraint. | no |
| 5 | nit | `cmd/server/main.go` (lines 40–49) | `purgeExpiredSessions` goroutine has no stop channel and cannot be shut down cleanly. Harmless in practice (OS reclaims on exit) but leaves dangling goroutine state. | no |
### Required fixes
**Finding #1 — move templates to separate files**
Move `loginTemplate` and `adminUsersTemplate` out of `handler.go` and into:
- `internal/web/templates/login.html`
- `internal/web/templates/admin_users.html`
Load them via `embed.FS` or `template.ParseFiles` in a shared template registry (or individually per handler). Structure the templates so T-004 can add `{{block "content" .}}` / `{{block "title" .}}` inheritance without touching auth code. The login page may stay standalone (pre-auth, no sidebar). `admin_users.html` at minimum must be a separate file so T-004 can wrap it in the base shell.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/auth/handler.go`, `internal/auth/middleware.go`, `internal/auth/store.go`, `internal/auth/handler_test.go`, `internal/auth/store_test.go`, `internal/db/db.go`, `internal/web/router.go`, `internal/web/router_test.go`, `cmd/server/main.go`, `go.mod`.
2. Cross-checked implementation against `.ai/PLAN.md` Phase 2 scope and repository layout.
3. Ran `go fmt ./...` — clean.
4. Ran `go vet ./...` — clean.
5. Ran `go test ./...``internal/auth` PASS, `internal/web` PASS.
6. Ran `go test -race ./...` — PASS, no data races.
7. Ran `go test ./internal/auth/... ./internal/web/... -v` — all 9 tests pass (login renders, valid/invalid credentials, admin gate, store auth, expired session, ensure-admin idempotency, health, unauthenticated redirect).
**Findings:**
- All acceptance criteria met at the functional level: login renders, valid credentials redirect + set cookie, invalid returns 401, admin gate enforced, unauthenticated routes redirect.
- SQLite schema matches plan exactly (users + sessions tables, WAL mode, foreign keys).
- `EnsureAdmin` idempotency tested and correct. bcrypt + crypto/rand token generation correct. Cookie expiry delete pattern (`MaxAge: -1`) is correct. Token is 32-byte base64url — adequate entropy.
- Main structural deviation is inline templates (finding #1), which blocks clean T-004 integration.
**Risks:**
- If finding #1 is not fixed, T-004 will need a refactor spike to migrate inline templates to file-based ones before base template inheritance can work, increasing T-004 scope unexpectedly.
### Verification
**Steps performed:**
@ -45,3 +91,36 @@ None — all findings are nits. No blocking issues.
- `.env.example` gaps (finding #1) are low-risk for a scaffold task but should be cleaned up before shipping.
---
## T-002 — Auth (rework pass)
**Verdict:** PASS
### Findings
All findings from the initial review pass addressed or confirmed non-blocking. No new findings.
### Required fixes
None.
### Verification
**Steps performed:**
1. Read reworked files: `internal/auth/handler.go`, `internal/auth/handler_test.go`, `internal/web/templates/templates.go`, `internal/web/templates/login.html`, `internal/web/templates/admin_users.html`.
2. Confirmed blocking finding #1 resolved: templates moved to `internal/web/templates/*.html`; `templates.go` exposes `embed.FS` with `//go:embed *.html` and named string constants; `handler.go` loads via `template.ParseFS(webtemplates.FS, ...)` and renders via `ExecuteTemplate`.
3. Tests extended with `readTemplateFile` / `renderTemplateFile` helpers that read from disk, proving file-backed rendering is live (not a cached inline string).
4. Ran `go fmt ./...` — clean.
5. Ran `go vet ./...` — clean.
6. Ran `go test ./...` — all packages PASS.
7. Ran `go test -race ./...` — PASS, no data races.
**Findings:**
- `templates.go` `embed.FS` gives T-004 a clean extension point — it can register `base.html` in the same package and `admin_users.html` can reference it when T-004 lands.
- `template.ParseFS` + `ExecuteTemplate(w, "login.html", nil)` pattern is correct; template name matches the file name.
- Non-blocking nits from initial review (no `Secure` cookie flag, no last-admin guard, go 1.25.0 version bump, non-stoppable purge goroutine) remain; none block this task.
**Risks:**
- When T-004 adds `base.html` to the embed glob, template parse order matters; the implementer should parse base first or use `template.ParseFS(..., "base.html", "admin_users.html")` to ensure the base is available before extension templates.
---

2
.ai/TASKS.md

@ -21,7 +21,7 @@ Command expectations:
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
| --- | --- | --- | --- | --- | --- |
| T-001 | Project scaffold: Go module, chi router, health endpoint, multi-stage Dockerfile (debian:slim + ffmpeg), docker-compose.yml, .env.example, README.md | done | `docker compose build` succeeds; `GET /health` returns 200; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `docker compose build`, `curl -i http://127.0.0.1:18080/health` passed | none |
| T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | ready_for_implement | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | n/a | implement |
| T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | done | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `go test ./internal/auth/...` passed after template rework | none |
| T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | ready_for_implement | Scanner correctly indexes fixture directory with known dates/files; `DayList()` returns sorted dates; `DayEntry` contains sorted images and videos; periodic rescan runs without data race (`go test -race ./internal/footage/...` passes) | n/a | implement |
| T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | ready_for_implement | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | n/a | implement |
| T-005 | Image browser: raw JPEG serving, in-memory LRU thumbnail cache (160×90), thumbnail strip, full-image viewer, arrow navigation (keyboard + on-screen), deep-linkable `?idx=N` | ready_for_implement | Thumbnail endpoint returns JPEG ≤30 KB for a test image; raw endpoint serves original file unchanged; arrow nav advances and wraps correctly; keyboard left/right works; strip highlights active item; `go test ./internal/image/...` passes | n/a | implement |

18
README.md

@ -2,7 +2,7 @@
## Overview
CamMonitor is a self-hosted security camera footage viewer. This scaffold provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup, and Docker Compose deployment.
CamMonitor is a self-hosted security camera footage viewer. It provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup and migrations, bcrypt login, session cookies, admin user management, and Docker Compose deployment.
## Getting Started
@ -30,6 +30,8 @@ The response should be:
{"status":"ok"}
```
Open `http://localhost:8080/login` and sign in with `ADMIN_USER` and `ADMIN_PASS`. On the first run, CamMonitor creates that bootstrap admin account only when the user table is empty. After login, admins can add or delete users at `/admin/users`.
## Configuration
| Variable | Default | Description |
@ -38,11 +40,19 @@ The response should be:
| `LISTEN_ADDR` | `:8080` | TCP address used by the Go server |
| `FOOTAGE_ROOT` | `/footage` | Camera footage directory inside the container |
| `DB_PATH` | `/data/cammonitor.db` | SQLite database path |
| `ADMIN_USER` | `admin` | Bootstrap admin username for the auth phase |
| `ADMIN_PASS` | `changeme` | Bootstrap admin password for the auth phase |
| `SESSION_TTL` | `24h` | Session lifetime |
| `ADMIN_USER` | `admin` | Bootstrap admin username created on first run when no users exist |
| `ADMIN_PASS` | `changeme` | Bootstrap admin password created on first run when no users exist |
| `SESSION_TTL` | `24h` | Session cookie lifetime |
| `SCAN_INTERVAL` | `5m` | Footage rescan interval |
## Authentication
All app routes except `/health`, `/login`, and `POST /login` require a valid `session` cookie. Unauthenticated requests are redirected to `/login`. Admin-only pages return `403 Forbidden` for signed-in non-admin users.
The login form posts credentials to `/login`. Successful login sets an HTTP-only `session` cookie and redirects to `/`; invalid credentials return `401 Unauthorized`. `POST /logout` deletes the server-side session, clears the cookie, and redirects back to `/login`.
Admin users can manage accounts at `/admin/users`. New users are created with bcrypt-hashed passwords, and deleting a user also removes their sessions through the SQLite foreign key cascade.
## Footage Layout
Footage is mounted read-only at `/footage` in the container. The planned directory format is:

19
cmd/server/main.go

@ -3,7 +3,9 @@ package main
import (
"log"
"net/http"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/web"
@ -21,6 +23,12 @@ func main() {
}
defer database.Close()
authStore := auth.NewStore(database)
if err := authStore.EnsureAdmin(cfg.AdminUser, cfg.AdminPass); err != nil {
log.Fatalf("ensure admin: %v", err)
}
go purgeExpiredSessions(authStore)
router := web.NewRouter(cfg, database, nil)
log.Printf("listening on %s", cfg.ListenAddr)
@ -28,3 +36,14 @@ func main() {
log.Fatalf("server stopped: %v", err)
}
}
func purgeExpiredSessions(store *auth.Store) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := store.PurgeExpiredSessions(); err != nil {
log.Printf("purge expired sessions: %v", err)
}
}
}

5
go.mod

@ -1,9 +1,10 @@
module github.com/domagojzecevic/cammonitor
go 1.22
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.3
golang.org/x/crypto v0.31.0
modernc.org/sqlite v1.34.5
)
@ -13,7 +14,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect

6
go.sum

@ -12,11 +12,13 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=

122
internal/auth/handler.go

@ -0,0 +1,122 @@
package auth
import (
"errors"
"html/template"
"net/http"
"strconv"
"time"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
store *Store
sessionTTL time.Duration
}
func NewHandler(store *Store, sessionTTL time.Duration) *Handler {
return &Handler{store: store, sessionTTL: sessionTTL}
}
func (h *Handler) LoginPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = loginTemplate.ExecuteTemplate(w, webtemplates.Login, nil)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
user, err := h.store.Authenticate(r.FormValue("username"), r.FormValue("password"))
if errors.Is(err, ErrInvalidCredentials) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
token, err := h.store.CreateSession(user.ID, h.sessionTTL)
if err != nil {
http.Error(w, "create session failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: time.Now().UTC().Add(h.sessionTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = h.store.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handler) UsersPage(w http.ResponseWriter, _ *http.Request) {
users, err := h.store.ListUsers()
if err != nil {
http.Error(w, "list users failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = adminUsersTemplate.ExecuteTemplate(w, webtemplates.AdminUsers, struct {
Users []User
}{Users: users})
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if err := h.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("is_admin") == "on"); err != nil {
http.Error(w, "create user failed", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
if err := h.store.DeleteUser(id); err != nil {
http.Error(w, "delete user failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
var loginTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.Login))
var adminUsersTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.AdminUsers))

192
internal/auth/handler_test.go

@ -0,0 +1,192 @@
package auth
import (
"bytes"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoginPageRenders(t *testing.T) {
handler, _, _ := newTestHandler(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
response := httptest.NewRecorder()
handler.LoginPage(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
if body := response.Body.String(); !strings.Contains(body, `<form`) || !strings.Contains(body, `name="username"`) {
t.Fatalf("login page missing expected form fields: %s", body)
}
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(readTemplateFile(t, "login.html")); got != want {
t.Fatalf("login page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
func TestLoginValidCredentialsSetSessionCookieAndRedirect(t *testing.T) {
handler, store, _ := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
form := url.Values{"username": {"alice"}, "password": {"secret"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.Login(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected 303, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/" {
t.Fatalf("expected redirect to /, got %q", location)
}
cookie := findCookie(response.Result().Cookies(), sessionCookieName)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected session cookie, got %#v", response.Result().Cookies())
}
if _, err := store.GetSession(cookie.Value); err != nil {
t.Fatalf("session cookie was not persisted: %v", err)
}
}
func TestLoginInvalidCredentialsReturnsUnauthorized(t *testing.T) {
handler, store, _ := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
form := url.Values{"username": {"alice"}, "password": {"wrong"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.Login(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", response.Code)
}
}
func TestAdminUsersRequiresAdmin(t *testing.T) {
handler, store, mux := newTestHandler(t)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create regular user: %v", err)
}
if err := store.CreateUser("admin", "secret", true); err != nil {
t.Fatalf("create admin user: %v", err)
}
mux.Handle("/admin/users", RequireAuth(store)(RequireAdmin(http.HandlerFunc(handler.UsersPage))))
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected unauthenticated redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
regularCookie := sessionCookie(t, store, "alice", "secret")
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
request.AddCookie(regularCookie)
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected regular user forbidden, got %d", response.Code)
}
adminCookie := sessionCookie(t, store, "admin", "secret")
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
request.AddCookie(adminCookie)
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected admin page, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "admin") {
t.Fatalf("admin page missing user list: %s", response.Body.String())
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(renderTemplateFile(t, "admin_users.html", struct {
Users []User
}{Users: users})); got != want {
t.Fatalf("admin users page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got)
}
}
func newTestHandler(t *testing.T) (*Handler, *Store, *http.ServeMux) {
t.Helper()
database := openTestDB(t)
store := NewStore(database)
handler := NewHandler(store, time.Hour)
return handler, store, http.NewServeMux()
}
func sessionCookie(t *testing.T, store *Store, username, password string) *http.Cookie {
t.Helper()
user, err := store.Authenticate(username, password)
if err != nil {
t.Fatalf("authenticate %s: %v", username, err)
}
token, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("create session: %v", err)
}
return &http.Cookie{Name: sessionCookieName, Value: token}
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}
func readTemplateFile(t *testing.T, name string) string {
t.Helper()
content, err := os.ReadFile(filepath.Join("..", "web", "templates", name))
if err != nil {
t.Fatalf("read template %s: %v", name, err)
}
return string(content)
}
func renderTemplateFile(t *testing.T, name string, data any) string {
t.Helper()
tmpl, err := template.ParseFiles(filepath.Join("..", "web", "templates", name))
if err != nil {
t.Fatalf("parse template %s: %v", name, err)
}
var output bytes.Buffer
if err := tmpl.ExecuteTemplate(&output, name, data); err != nil {
t.Fatalf("execute template %s: %v", name, err)
}
return output.String()
}

50
internal/auth/middleware.go

@ -0,0 +1,50 @@
package auth
import (
"context"
"net/http"
)
const sessionCookieName = "session"
type contextKey string
const userContextKey contextKey = "user"
func RequireAuth(store *Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
session, err := store.GetSession(cookie.Value)
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userContextKey, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok || !user.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userContextKey).(*User)
return user, ok
}

208
internal/auth/store.go

@ -0,0 +1,208 @@
package auth
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
var ErrSessionNotFound = errors.New("session not found")
type Store struct {
db *sql.DB
}
type User struct {
ID int64
Username string
IsAdmin bool
}
type Session struct {
Token string
UserID int64
ExpiresAt time.Time
User *User
}
func NewStore(database *sql.DB) *Store {
return &Store{db: database}
}
func (s *Store) EnsureAdmin(username, password string) error {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return fmt.Errorf("admin username and password are required")
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
return s.CreateUser(username, password, true)
}
func (s *Store) CreateUser(username, password string, isAdmin bool) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
adminValue := 0
if isAdmin {
adminValue = 1
}
if _, err := s.db.Exec(
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`,
username,
string(hash),
adminValue,
); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (s *Store) DeleteUser(id int64) error {
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
}
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, is_admin FROM users ORDER BY username`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
var isAdmin int
if err := rows.Scan(&user.ID, &user.Username, &isAdmin); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
user.IsAdmin = isAdmin != 0
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate users: %w", err)
}
return users, nil
}
func (s *Store) Authenticate(username, password string) (*User, error) {
var user User
var hash string
var isAdmin int
err := s.db.QueryRow(
`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`,
strings.TrimSpace(username),
).Scan(&user.ID, &user.Username, &hash, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials
}
if err != nil {
return nil, fmt.Errorf("load user: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
user.IsAdmin = isAdmin != 0
return &user, nil
}
func (s *Store) CreateSession(userID int64, ttl time.Duration) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("generate session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
expiresAt := time.Now().UTC().Add(ttl)
if _, err := s.db.Exec(
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
token,
userID,
expiresAt.Format(time.RFC3339Nano),
); err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return token, nil
}
func (s *Store) GetSession(token string) (*Session, error) {
var session Session
var expiresAt string
var user User
var isAdmin int
err := s.db.QueryRow(
`SELECT s.token, s.user_id, s.expires_at, u.username, u.is_admin
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?`,
token,
).Scan(&session.Token, &session.UserID, &expiresAt, &user.Username, &isAdmin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("load session: %w", err)
}
parsedExpiresAt, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil, fmt.Errorf("parse session expiry: %w", err)
}
if !parsedExpiresAt.After(time.Now().UTC()) {
_ = s.DeleteSession(token)
return nil, ErrSessionNotFound
}
user.ID = session.UserID
user.IsAdmin = isAdmin != 0
session.ExpiresAt = parsedExpiresAt
session.User = &user
return &session, nil
}
func (s *Store) DeleteSession(token string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
func (s *Store) PurgeExpiredSessions() error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("purge expired sessions: %w", err)
}
return nil
}

101
internal/auth/store_test.go

@ -0,0 +1,101 @@
package auth
import (
"database/sql"
"testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/db"
)
func TestStoreAuthenticateHappyPathAndWrongPassword(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := store.Authenticate("alice", "secret")
if err != nil {
t.Fatalf("authenticate valid user: %v", err)
}
if user.Username != "alice" {
t.Fatalf("expected alice, got %q", user.Username)
}
if user.IsAdmin {
t.Fatal("expected regular user")
}
if _, err := store.Authenticate("alice", "wrong"); err == nil {
t.Fatal("expected wrong password to fail")
}
}
func TestStoreExpiredSessionIsRejected(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.CreateUser("alice", "secret", false); err != nil {
t.Fatalf("create user: %v", err)
}
user, err := store.Authenticate("alice", "secret")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
token, err := store.CreateSession(user.ID, -time.Minute)
if err != nil {
t.Fatalf("create session: %v", err)
}
if _, err := store.GetSession(token); err == nil {
t.Fatal("expected expired session to fail")
}
}
func TestEnsureAdminCreatesFirstRunAdminOnlyWhenEmpty(t *testing.T) {
database := openTestDB(t)
store := NewStore(database)
if err := store.EnsureAdmin("admin", "secret"); err != nil {
t.Fatalf("ensure admin: %v", err)
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
if len(users) != 1 || users[0].Username != "admin" || !users[0].IsAdmin {
t.Fatalf("unexpected users after bootstrap: %#v", users)
}
if err := store.EnsureAdmin("other", "secret"); err != nil {
t.Fatalf("ensure admin second run: %v", err)
}
users, err = store.ListUsers()
if err != nil {
t.Fatalf("list users second run: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected no second bootstrap user, got %d", len(users))
}
}
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
database, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
return database
}

30
internal/db/db.go

@ -34,5 +34,35 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(database); err != nil {
database.Close()
return nil, err
}
return database, nil
}
func migrate(database *sql.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL
)`,
}
for _, statement := range statements {
if _, err := database.Exec(statement); err != nil {
return fmt.Errorf("run migration: %w", err)
}
}
return nil
}

30
internal/web/router.go

@ -5,11 +5,12 @@ import (
"encoding/json"
"net/http"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/go-chi/chi/v5"
)
func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router {
func NewRouter(cfg *config.Config, database *sql.DB, _ any) chi.Router {
router := chi.NewRouter()
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
@ -18,5 +19,32 @@ func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
if cfg == nil || database == nil {
return router
}
store := auth.NewStore(database)
authHandler := auth.NewHandler(store, cfg.SessionTTL)
router.Get("/login", authHandler.LoginPage)
router.Post("/login", authHandler.Login)
router.Post("/logout", authHandler.Logout)
router.Group(func(protected chi.Router) {
protected.Use(auth.RequireAuth(store))
protected.Get("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("CamMonitor"))
})
protected.Group(func(admin chi.Router) {
admin.Use(auth.RequireAdmin)
admin.Get("/admin/users", authHandler.UsersPage)
admin.Post("/admin/users", authHandler.CreateUser)
admin.Post("/admin/users/{id}/delete", authHandler.DeleteUser)
})
})
return router
}

32
internal/web/router_test.go

@ -4,7 +4,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
)
func TestHealthReturnsOK(t *testing.T) {
@ -28,3 +33,30 @@ func TestHealthReturnsOK(t *testing.T) {
t.Fatalf("expected status ok, got %q", body["status"])
}
}
func TestAdminUsersRedirectsWithoutSessionCookie(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "cammonitor.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
})
router := NewRouter(&config.Config{SessionTTL: time.Hour}, database, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, response.Code)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
}

39
internal/web/templates/admin_users.html

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Users - CamMonitor</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 p-6 text-slate-100">
<main class="mx-auto max-w-3xl space-y-6">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Users</h1>
<form method="post" action="/logout"><button class="rounded bg-slate-800 px-3 py-2">Sign out</button></form>
</header>
<form method="post" action="/admin/users" class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]">
<input name="username" placeholder="Username" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700">
<input name="password" type="password" placeholder="Password" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700">
<label class="flex items-center gap-2 text-sm"><input name="is_admin" type="checkbox"> Admin</label>
<button class="rounded bg-indigo-500 px-3 py-2 font-medium text-white">Add</button>
</form>
<table class="w-full border-collapse text-left">
<thead><tr class="border-b border-slate-800"><th class="py-2">Username</th><th class="py-2">Role</th><th class="py-2"></th></tr></thead>
<tbody>
{{range .Users}}
<tr class="border-b border-slate-900">
<td class="py-2">{{.Username}}</td>
<td class="py-2">{{if .IsAdmin}}admin{{else}}user{{end}}</td>
<td class="py-2 text-right">
<form method="post" action="/admin/users/{{.ID}}/delete">
<button class="rounded bg-slate-800 px-3 py-1">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</main>
</body>
</html>

25
internal/web/templates/login.html

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login - CamMonitor</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto flex min-h-screen max-w-sm items-center px-6">
<form method="post" action="/login" class="w-full space-y-4">
<h1 class="text-2xl font-semibold">CamMonitor</h1>
<label class="block space-y-1">
<span class="text-sm text-slate-300">Username</span>
<input name="username" autocomplete="username" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700">
</label>
<label class="block space-y-1">
<span class="text-sm text-slate-300">Password</span>
<input name="password" type="password" autocomplete="current-password" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700">
</label>
<button type="submit" class="w-full rounded bg-indigo-500 px-3 py-2 font-medium text-white">Sign in</button>
</form>
</main>
</body>
</html>

13
internal/web/templates/templates.go

@ -0,0 +1,13 @@
package templates
import "embed"
const (
Login = "login.html"
AdminUsers = "admin_users.html"
)
// FS exposes file-backed HTML templates for handlers that render web pages.
//
//go:embed *.html
var FS embed.FS
Loading…
Cancel
Save