From 18d81da455215465a50f8e7de44af863d053cf4d Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Thu, 18 Jun 2026 10:55:00 +0200 Subject: [PATCH] feat(auth): add login and admin user management --- .ai/HANDOFF.md | 67 ++++++++ .ai/REVIEW.md | 79 +++++++++ .ai/TASKS.md | 2 +- README.md | 18 +- cmd/server/main.go | 19 +++ go.mod | 5 +- go.sum | 6 +- internal/auth/handler.go | 122 ++++++++++++++ internal/auth/handler_test.go | 192 ++++++++++++++++++++++ internal/auth/middleware.go | 50 ++++++ internal/auth/store.go | 208 ++++++++++++++++++++++++ internal/auth/store_test.go | 101 ++++++++++++ internal/db/db.go | 30 ++++ internal/web/router.go | 30 +++- internal/web/router_test.go | 32 ++++ internal/web/templates/admin_users.html | 39 +++++ internal/web/templates/login.html | 25 +++ internal/web/templates/templates.go | 13 ++ 18 files changed, 1028 insertions(+), 10 deletions(-) create mode 100644 internal/auth/handler.go create mode 100644 internal/auth/handler_test.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/store_test.go create mode 100644 internal/web/templates/admin_users.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/templates.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index ec0f5f0..7e483e3 100644 --- a/.ai/HANDOFF.md +++ b/.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 | + +--- diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index de1b7bc..3518170 100644 --- a/.ai/REVIEW.md +++ b/.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. + +--- diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 0fd50ea..20c8dbf 100644 --- a/.ai/TASKS.md +++ b/.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 | diff --git a/README.md b/README.md index d196227..c95d270 100644 --- a/README.md +++ b/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: diff --git a/cmd/server/main.go b/cmd/server/main.go index 4659873..76a3ebb 100644 --- a/cmd/server/main.go +++ b/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) + } + } +} diff --git a/go.mod b/go.mod index 70b0725..1dd3030 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 44e266e..6354d4d 100644 --- a/go.sum +++ b/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= diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..eac3527 --- /dev/null +++ b/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)) diff --git a/internal/auth/handler_test.go b/internal/auth/handler_test.go new file mode 100644 index 0000000..d945193 --- /dev/null +++ b/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, ` 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 +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 0000000..03fb54b --- /dev/null +++ b/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 +} diff --git a/internal/db/db.go b/internal/db/db.go index dbbfd77..e4ec431 100644 --- a/internal/db/db.go +++ b/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 +} diff --git a/internal/web/router.go b/internal/web/router.go index 06380ec..e052bd2 100644 --- a/internal/web/router.go +++ b/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 } diff --git a/internal/web/router_test.go b/internal/web/router_test.go index 254defe..264ee0d 100644 --- a/internal/web/router_test.go +++ b/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) + } +} diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html new file mode 100644 index 0000000..5ac5715 --- /dev/null +++ b/internal/web/templates/admin_users.html @@ -0,0 +1,39 @@ + + + + + + Users - CamMonitor + + + +
+
+

Users

+
+
+
+ + + + +
+ + + + {{range .Users}} + + + + + + {{end}} + +
UsernameRole
{{.Username}}{{if .IsAdmin}}admin{{else}}user{{end}} +
+ +
+
+
+ + diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..4b6f69c --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,25 @@ + + + + + + Login - CamMonitor + + + +
+
+

CamMonitor

+ + + +
+
+ + diff --git a/internal/web/templates/templates.go b/internal/web/templates/templates.go new file mode 100644 index 0000000..82e92e4 --- /dev/null +++ b/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