diff --git a/.eslintrc.json b/.eslintrc.json index d4193f8c..a469ec57 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,7 @@ "CACHE_INVALIDATE_MS": "readonly", "CLIENT_CONFIG": "readonly", "CLIENT_TTL": "readonly", + "CSAuth": "readonly", "ChannelColorPicker": "readonly", "ChannelColors": "readonly", "ChannelDecrypt": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f6c4b18a..97a22f7d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -188,6 +188,8 @@ jobs: # -race gates PR #1208's atomic.Pointer migration: the race-detector # is what makes path_inspect_atomic_race_test.go actually assert. go test -timeout 20m -race -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log + # The e2etest build (fake mailer + /__e2e/last-mail) has its own tests. + go test -tags e2etest -count=1 -run 'E2E' . echo "--- Go Server Coverage ---" go tool cover -func=server-coverage.out | tail -1 @@ -211,6 +213,13 @@ jobs: go test ./... echo "--- Decrypt CLI tests passed ---" + - name: Test user-management modules (users, mailer) + run: | + set -e -o pipefail + cd internal/users && go test -race ./... + cd ../mailer && go test -race ./... + echo "--- users + mailer tests passed ---" + - name: Test migrate CLI + dbschema run: | set -e -o pipefail @@ -352,6 +361,7 @@ jobs: run: | cd cmd/server go build -o ../../corescope-server . + go build -tags e2etest -o ../../corescope-server-e2e . echo "Go server built successfully" - name: Build Go migrate tool @@ -476,6 +486,33 @@ jobs: sleep 1 done + - name: Start user-management E2E server (fake mailer) + run: | + fuser -k 13582/tcp 2>/dev/null || true + rm -rf /tmp/cs-um + mkdir -p /tmp/cs-um + # Own copy of the (already migrated and seeded) fixture; fresh users.db every run. + cp test-fixtures/e2e-fixture.db /tmp/cs-um/on.db + cat > /tmp/cs-um/config.json <<'JSON' + { + "port": 13582, + "userManagement": { + "enabled": true, + "dbPath": "/tmp/cs-um/users.db", + "adminEmails": ["admin@e2e.test"], + "publicBaseUrl": "http://localhost:13582", + "mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" } + } + } + JSON + ./corescope-server-e2e -config-dir /tmp/cs-um -port 13582 -db /tmp/cs-um/on.db -public public-instrumented & + echo $! > .server-um.pid + for i in $(seq 1 30); do + if curl -sf http://localhost:13582/api/healthz > /dev/null 2>&1; then echo "UM server ready after ${i}s"; break; fi + if [ "$i" -eq 30 ]; then echo "UM server failed to start within 30s"; exit 1; fi + sleep 1 + done + - name: Run Playwright E2E tests (fail-fast) run: | echo "=== E2E SUITE: test-e2e-playwright.js ===" @@ -728,6 +765,8 @@ jobs: BASE_URL=http://localhost:13581 node tests/e2e/test-nodes-export-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-show-neighbors.js ===" BASE_URL=http://localhost:13581 node tests/e2e/test-show-neighbors.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-user-management-e2e.js ===" + BASE_URL=http://localhost:13582 BASE_URL_OFF=http://localhost:13581 node tests/e2e/test-user-management-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so @@ -793,6 +832,10 @@ jobs: kill $(cat .server.pid) 2>/dev/null || true rm -f .server.pid fi + if [ -f .server-um.pid ]; then + kill $(cat .server-um.pid) 2>/dev/null || true + rm -f .server-um.pid + fi - name: Upload E2E badges if: success() diff --git a/AGENTS.md b/AGENTS.md index badc8208..1498d386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,16 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in ### Read/Write Separation Invariant (#1283) - **All DB writes live in `cmd/ingestor/`.** INSERT / UPDATE / DELETE / VACUUM / schema migrations / retention all run in the ingestor process. -- **`cmd/server/` is read-only.** It opens SQLite with `mode=ro` and must not - acquire a write lock. Adding a write-side helper (e.g. a `cachedRW`-style - RW connection) regresses this invariant and races the ingestor → SQLITE_BUSY. +- **`cmd/server/` never writes measurement data.** It opens the analyzer DB with + `mode=ro` and must not acquire a write lock on it. Adding a write-side helper + (e.g. a `cachedRW`-style RW connection) regresses this invariant and races the + ingestor → SQLITE_BUSY. +- **Single exception: `users.db` (optional user management).** When + `userManagement.enabled`, the server owns a *separate* SQLite file through + `internal/users` only. `users.Open` refuses the analyzer DB path, and + `TestUsersOpenIsTheOnlyServerWritePath` pins the one call site. Account data + never goes into the analyzer DB, and measurement writes never go through + `internal/users`. - Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that `PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT methods on the server's `*DB`. If you need a new write, add it to diff --git a/Dockerfile b/Dockerfile index 3042081d..147c9bbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,6 +65,8 @@ COPY internal/prunequeue/ ../../internal/prunequeue/ COPY internal/perfio/ ../../internal/perfio/ COPY internal/mbcapqueue/ ../../internal/mbcapqueue/ COPY internal/lora/ ../../internal/lora/ +COPY internal/users/ ../../internal/users/ +COPY internal/mailer/ ../../internal/mailer/ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/tmp/zig-cache \ diff --git a/cmd/server/account_handlers.go b/cmd/server/account_handlers.go new file mode 100644 index 00000000..3b9d386b --- /dev/null +++ b/cmd/server/account_handlers.go @@ -0,0 +1,226 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +// checkCurrentPassword writes 403 and returns false on a wrong password. +func checkCurrentPassword(w http.ResponseWriter, u *users.User, password string) bool { + if ok, err := users.VerifyPassword(u.PasswordHash, password); err != nil || !ok { + writeError(w, http.StatusForbidden, "current password is incorrect") + return false + } + return true +} + +func (s *Server) handleAccountPatch(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) { + var req profileRequest + if !decodeJSON(w, r, &req) { + return + } + name, err := users.ValidateDisplayName(req.DisplayName) + if writeValidation(w, err) { + return + } + if err := s.auth.st.SetDisplayName(u.ID, name); err != nil { + log.Printf("[users] set display name for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + u.DisplayName = name + writeJSON(w, meFrom(u, sess)) +} + +func (s *Server) handleAccountPassword(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) { + a := s.auth + var req passwordChangeRequest + if !decodeJSON(w, r, &req) || !checkCurrentPassword(w, u, req.CurrentPassword) { + return + } + if writeValidation(w, users.ValidatePassword(req.NewPassword)) { + return + } + hash, err := users.HashPassword(req.NewPassword) + if err == nil { + err = a.st.SetPassword(u.ID, hash) + } + if err != nil { + log.Printf("[users] password change for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + // Sessions first: a token-store failure below must not leave another + // device's session alive. + if err := a.st.DeleteUserSessions(u.ID, sess.ID); err != nil { + log.Printf("[users] password change: end other sessions for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err := a.invalidateTokens("password change", u.ID, users.PurposeEmailChange, users.PurposeReset); err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(idPtr(u.ID), "user.password.change", idPtr(u.ID), nil) + writeJSON(w, okResponse{OK: true, Message: "Password changed. Your other devices were logged out."}) +} + +// handleAccountEmail answers and mails the requester identically whether +// the new address is free or taken (no enumeration): the old address gets +// the notice either way, and only a free address gets the confirmation. +func (s *Server) handleAccountEmail(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) { + a := s.auth + var req emailChangeRequest + if !decodeJSON(w, r, &req) { + return + } + newEmail, err := users.NormalizeEmail(req.NewEmail) + if writeValidation(w, err) { + return + } + if newEmail == u.Email { + writeError(w, http.StatusBadRequest, "that is already your address") + return + } + if !a.allow(w, r, a.signup, "emailchange:#"+strconv.FormatInt(u.ID, 10), "email:"+newEmail) || + !checkCurrentPassword(w, u, req.CurrentPassword) { + return + } + switch _, err := a.st.GetByEmail(newEmail); { + case err == nil: + // Taken: no confirmation, but the newest request still replaces + // any earlier link, as in the free branch. + if err := a.invalidateTokens("email change", u.ID, users.PurposeEmailChange); err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + // The notice is the only mail here; if it fails, answer like a + // failed confirmation in the free branch. + if err := a.sendMail(r.Context(), u, "email-change-notice", a.emailChangeNoticeMail(u, newEmail)); err != nil { + writeError(w, http.StatusServiceUnavailable, msgMailFailed) + return + } + case errors.Is(err, users.ErrNotFound): + err = a.mailToken(r.Context(), u, users.PurposeEmailChange, 24*time.Hour, newEmail, "email-change", + func(tok string) mailer.Message { return a.emailChangeConfirmMail(u, newEmail, tok) }) + if err != nil { + writeError(w, http.StatusServiceUnavailable, msgMailFailed) + return + } + // Best effort: sendMail logs a failure; the confirmation already went out. + _ = a.sendMail(r.Context(), u, "email-change-notice", a.emailChangeNoticeMail(u, newEmail)) + default: + log.Printf("[users] email change lookup for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(idPtr(u.ID), "user.email.change.requested", idPtr(u.ID), nil) + writeJSON(w, okResponse{OK: true, Message: "Check the new address for a confirmation link."}) +} + +// handleConfirmEmail needs no session: the link may be opened on any device. +func (s *Server) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req tokenRequest + if !decodeJSON(w, r, &req) { + return + } + uid, newEmail, err := a.st.ConsumeToken(req.Token, users.PurposeEmailChange) + if err != nil { + writeTokenError(w, err) + return + } + // Only an active account may change its address; anything else reads + // like a dead link. + u, err := a.st.GetByID(uid) + if err != nil && !errors.Is(err, users.ErrNotFound) { + log.Printf("[users] confirm email: load user #%d: %v", uid, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err != nil || u.Status != users.StatusActive { + writeTokenError(w, users.ErrTokenInvalid) + return + } + if err := a.st.SetEmail(uid, newEmail); err != nil { + if errors.Is(err, users.ErrEmailTaken) { + writeError(w, http.StatusConflict, "that address is already in use") + return + } + log.Printf("[users] confirm email for user #%d: %v", uid, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(idPtr(uid), "user.email.change", idPtr(uid), nil) + writeJSON(w, okResponse{OK: true, Message: "Your address was changed."}) +} + +func (s *Server) handleAccountSessions(w http.ResponseWriter, _ *http.Request, u *users.User, sess *users.Session) { + list, err := s.auth.st.ListSessions(u.ID) + if err != nil { + log.Printf("[users] list sessions for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + out := make([]sessionJSON, 0, len(list)) + for _, x := range list { + out = append(out, sessionToJSON(x, sess.ID)) + } + writeJSON(w, out) +} + +func (s *Server) handleAccountSessionDelete(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid session id") + return + } + if err := s.auth.st.DeleteSession(u.ID, id); err != nil { + if errors.Is(err, users.ErrNotFound) { + writeError(w, http.StatusNotFound, "session not found") + return + } + log.Printf("[users] delete session for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if id == sess.ID { + s.auth.clearSessionCookie(w) + } + writeJSON(w, okResponse{OK: true}) +} + +func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) { + a := s.auth + var req passwordConfirmRequest + if !decodeJSON(w, r, &req) || !checkCurrentPassword(w, u, req.CurrentPassword) { + return + } + if u.Role == users.RoleAdmin { + n, err := a.st.CountActiveAdmins() + if err != nil { + log.Printf("[users] self-delete: count admins for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if n <= 1 { + writeError(w, http.StatusConflict, "you are the last admin; promote someone else first") + return + } + } + if err := a.st.Delete(u.ID); err != nil { + log.Printf("[users] self-delete user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(idPtr(u.ID), "user.delete.self", idPtr(u.ID), nil) + a.clearSessionCookie(w) + writeJSON(w, okResponse{OK: true, Message: "Your account was deleted."}) +} diff --git a/cmd/server/account_test.go b/cmd/server/account_test.go new file mode 100644 index 00000000..554bb52e --- /dev/null +++ b/cmd/server/account_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "errors" + "fmt" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/meshcore-analyzer/users" +) + +func TestAccountProfileAndCSRF(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "gil@example.org", "Gil", pw) + noCSRF := &client{cookie: c.cookie} + expectStatus(t, f.do("PATCH", "/api/account", profileRequest{DisplayName: "Gilbert"}, as(noCSRF)), 403) + expectStatus(t, f.do("PATCH", "/api/account", profileRequest{DisplayName: "Gilbert"}, as(c), header("Origin", "https://evil.example")), 403) + w := f.do("PATCH", "/api/account", profileRequest{DisplayName: "Gilbert"}, as(c)) + expectStatus(t, w, 200) + if decode[meResponse](t, w).DisplayName != "Gilbert" { + t.Fatal("display name not changed") + } + expectStatus(t, f.do("PATCH", "/api/account", profileRequest{DisplayName: "G"}, as(c)), 400) +} + +func TestAccountPasswordChangeKeepsCurrentSession(t *testing.T) { + f := newAuthFixture(t) + a := f.registerAndActivate(t, "hal@example.org", "Hal", pw) + b := f.login(t, "hal@example.org", pw) + expectStatus(t, f.do("POST", "/api/account/password", passwordChangeRequest{CurrentPassword: "wrong one!!", NewPassword: "new secret pass"}, as(a)), 403) + expectStatus(t, f.do("POST", "/api/account/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "new secret pass"}, as(a)), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(a)), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(b)), 401) +} + +func TestAccountEmailChange(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "ivy@example.org", "Ivy", pw) + expectStatus(t, f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "ivy.new@example.org", CurrentPassword: pw}, as(c)), 200) + sent := f.fake.Sent() + confirm, notice := sent[len(sent)-2], sent[len(sent)-1] + if confirm.To != "ivy.new@example.org" || notice.To != "ivy@example.org" || !strings.Contains(notice.Text, "ivy.new@example.org") { + t.Fatalf("confirm=%+v notice=%+v", confirm, notice) + } + tok := tokenRE.FindStringSubmatch(confirm.Text)[1] + expectStatus(t, f.do("POST", "/api/account/confirm-email", tokenRequest{Token: tok}, header("Origin", "")), 403) // no Origin + expectStatus(t, f.do("POST", "/api/account/confirm-email", tokenRequest{Token: tok}), 200) + f.login(t, "ivy.new@example.org", pw) + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "ivy@example.org", Password: pw}), 401) +} + +func TestAccountSessionsListAndRevoke(t *testing.T) { + f := newAuthFixture(t) + a := f.registerAndActivate(t, "jo@example.org", "Jo", pw) + b := f.login(t, "jo@example.org", pw) + w := f.do("GET", "/api/account/sessions", nil, as(a)) + expectStatus(t, w, 200) + list := decode[[]sessionJSON](t, w) + if len(list) != 2 { + t.Fatalf("sessions = %+v", list) + } + var other int64 + for _, s := range list { + if !s.Current { + other = s.ID + } + } + expectStatus(t, f.do("DELETE", fmt.Sprintf("/api/account/sessions/%d", other), nil, as(a)), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(b)), 401) + expectStatus(t, f.do("DELETE", "/api/account/sessions/999999", nil, as(a)), 404) +} + +func TestAccountDelete(t *testing.T) { + f := newAuthFixture(t, "boss@example.org") + boss := f.registerAndActivate(t, "boss@example.org", "Boss", pw) + expectStatus(t, f.do("DELETE", "/api/account", passwordConfirmRequest{CurrentPassword: pw}, as(boss)), 409) // last admin + u := f.registerAndActivate(t, "kim@example.org", "Kim", pw) + expectStatus(t, f.do("DELETE", "/api/account", passwordConfirmRequest{CurrentPassword: "wrong one!!"}, as(u)), 403) + w := f.do("DELETE", "/api/account", passwordConfirmRequest{CurrentPassword: pw}, as(u)) + expectStatus(t, w, 200) + if ck := w.Result().Cookies(); len(ck) == 0 || ck[0].MaxAge >= 0 { + t.Fatalf("cookie not cleared: %+v", ck) + } + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "kim@example.org", Password: pw}), 401) +} + +// pendingLinks requests an email change to newEmail and a password reset for +// c (whose address is email) and returns both unused links. +func pendingLinks(t *testing.T, f *authFixture, c *client, email, newEmail string) (confirm, reset string) { + t.Helper() + expectStatus(t, f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: newEmail, CurrentPassword: pw}, as(c), fromIP("192.0.2.1")), 200) + sent := f.fake.Sent() + confirm, _ = url.QueryUnescape(tokenRE.FindStringSubmatch(sent[len(sent)-2].Text)[1]) + expectStatus(t, f.do("POST", "/api/auth/forgot", emailRequest{Email: email}, fromIP("192.0.2.2")), 200) + return confirm, f.lastToken(t) +} + +func TestAuthResetKillsPendingEmailChange(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "nia@example.org", "Nia", pw) + confirm, reset := pendingLinks(t, f, c, "nia@example.org", "attacker@example.org") + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: reset, Password: "a brand new secret"}), 200) + expectStatus(t, f.do("POST", "/api/account/confirm-email", tokenRequest{Token: confirm}), 410) + f.login(t, "nia@example.org", "a brand new secret") +} + +func TestAccountPasswordChangeKillsPendingLinks(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "oli@example.org", "Oli", pw) + confirm, reset := pendingLinks(t, f, c, "oli@example.org", "attacker@example.org") + expectStatus(t, f.do("POST", "/api/account/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "new secret pass"}, as(c)), 200) + expectStatus(t, f.do("POST", "/api/account/confirm-email", tokenRequest{Token: confirm}), 410) + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: reset, Password: "another new secret"}), 410) + f.login(t, "oli@example.org", "new secret pass") +} + +// A token-store failure still answers 500, but only after the other +// sessions are gone: a thief's session must not outlive the change. +func TestAccountPasswordChangeTokenStoreFailureIs500(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "pam@example.org", "Pam", pw) + thief := f.login(t, "pam@example.org", pw) + f.breakTable(t, "tokens") + expectStatus(t, f.do("POST", "/api/account/password", passwordChangeRequest{CurrentPassword: pw, NewPassword: "new secret pass"}, as(c)), 500) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(thief)), 401) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(c)), 200) +} + +// Same for reset: the email-change invalidation fails, all sessions are +// already ended. +func TestAuthResetTokenStoreFailureEndsSessionsFirst(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "pia@example.org", "Pia", pw) + thief := f.login(t, "pia@example.org", pw) + _, reset := pendingLinks(t, f, c, "pia@example.org", "attacker@example.org") + f.execDB(t, `CREATE TRIGGER down BEFORE UPDATE ON tokens WHEN OLD.purpose = 'email_change' BEGIN + SELECT RAISE(ABORT, 'token store down'); END`) + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: reset, Password: "a brand new secret"}), 500) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(thief)), 401) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(c)), 401) +} + +func TestAccountEmailChangeTakenLooksLikeFree(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "quin@example.org", "Quin", pw) + f.registerAndActivate(t, "rae@example.org", "Rae", pw) + free := f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "quin.new@example.org", CurrentPassword: pw}, as(c), fromIP("192.0.2.1")) + before := len(f.fake.Sent()) + taken := f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "rae@example.org", CurrentPassword: pw}, as(c), fromIP("192.0.2.2")) + if free.Code != 200 || free.Code != taken.Code || free.Body.String() != taken.Body.String() { + t.Fatalf("responses differ: %d %q vs %d %q", free.Code, free.Body, taken.Code, taken.Body) + } + sent := f.fake.Sent()[before:] + if len(sent) != 1 || sent[0].To != "quin@example.org" || !strings.Contains(sent[0].Text, "rae@example.org") { + t.Fatalf("taken branch sent %+v, want only the notice to the old address", sent) + } +} + +func TestAccountEmailChangeMailFailureLeavesNoLink(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "sam@example.org", "Sam", pw) + f.fake.SetSendErr(errors.New("brevo down")) + expectStatus(t, f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "sam.new@example.org", CurrentPassword: pw}, as(c)), 503) + if n := f.unusedTokens(t, c.me.ID, users.PurposeEmailChange); n != 0 { + t.Fatalf("%d usable email-change links after a failed mail", n) + } +} + +// Taken branch: the notice to the old address is the only mail; when it +// fails the answer is 503, the address stays and no earlier link survives. +func TestAccountEmailChangeTakenNoticeFailureIs503(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "sol@example.org", "Sol", pw) + f.registerAndActivate(t, "tam@example.org", "Tam", pw) + expectStatus(t, f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "sol.new@example.org", CurrentPassword: pw}, as(c), fromIP("192.0.2.1")), 200) + f.fake.SetSendErr(errors.New("brevo down")) + w := f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "tam@example.org", CurrentPassword: pw}, as(c), fromIP("192.0.2.2")) + expectStatus(t, w, 503) + if !strings.Contains(w.Body.String(), msgMailFailed) { + t.Fatalf("body = %s", w.Body.String()) + } + if got, _ := f.st.GetByID(c.me.ID); got.Email != "sol@example.org" { + t.Fatalf("address changed to %q", got.Email) + } + if n := f.unusedTokens(t, c.me.ID, users.PurposeEmailChange); n != 0 { + t.Fatalf("%d usable email-change links after a failed notice", n) + } +} + +func TestAccountEmailChangeRateLimitPerUser(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "tia@example.org", "Tia", pw) + change := func(i int) *httptest.ResponseRecorder { + return f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: fmt.Sprintf("tia%d@example.org", i), CurrentPassword: pw}, + as(c), fromIP(fmt.Sprintf("198.51.100.%d", i+1))) + } + for i := 0; i < 5; i++ { + expectStatus(t, change(i), 200) + } + expectStatus(t, change(5), 429) +} + +func TestAccountEmailChangeRateLimitPerAddress(t *testing.T) { + f := newAuthFixture(t) + ip := 0 + change := func(c *client) *httptest.ResponseRecorder { + ip++ + return f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "target@example.org", CurrentPassword: pw}, + as(c), fromIP(fmt.Sprintf("198.51.100.%d", ip))) + } + a := f.registerAndActivate(t, "uli@example.org", "Uli", pw) + b := f.registerAndActivate(t, "val@example.org", "Val", pw) + d := f.registerAndActivate(t, "wes@example.org", "Wes", pw) + for i := 0; i < 3; i++ { + expectStatus(t, change(a), 200) + } + for i := 0; i < 2; i++ { + expectStatus(t, change(b), 200) + } + expectStatus(t, change(d), 429) +} + +func TestConfirmEmailForNonActiveUserIs410(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "xan@example.org", "Xan", pw) + expectStatus(t, f.do("POST", "/api/account/email", emailChangeRequest{NewEmail: "xan.new@example.org", CurrentPassword: pw}, as(c)), 200) + sent := f.fake.Sent() + tok, _ := url.QueryUnescape(tokenRE.FindStringSubmatch(sent[len(sent)-2].Text)[1]) + if err := f.st.SetStatus(c.me.ID, users.StatusDisabled); err != nil { // e.g. a direct DB edit; disable itself burns the link + t.Fatal(err) + } + w := f.do("POST", "/api/account/confirm-email", tokenRequest{Token: tok}) + expectStatus(t, w, 410) + if !strings.Contains(w.Body.String(), "this link is invalid or was already used") { + t.Fatalf("body = %s", w.Body.String()) + } + if got, _ := f.st.GetByID(c.me.ID); got.Email != "xan@example.org" { + t.Fatalf("address changed for a disabled user: %q", got.Email) + } +} diff --git a/cmd/server/admin_users_handlers.go b/cmd/server/admin_users_handlers.go new file mode 100644 index 00000000..a8d0a230 --- /dev/null +++ b/cmd/server/admin_users_handlers.go @@ -0,0 +1,328 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +// adminStoreFail answers a failed store call: ErrNotFound is 404, anything +// else is logged (user id only) and answered 500. +func adminStoreFail(w http.ResponseWriter, op string, id int64, err error) { + if errors.Is(err, users.ErrNotFound) { + writeError(w, http.StatusNotFound, "user not found") + return + } + log.Printf("[users] admin %s for user #%d: %v", op, id, err) + writeError(w, http.StatusInternalServerError, "internal error") +} + +// adminTarget loads {id} from the path; writes 400/404/500 and returns nil +// on failure. +func (s *Server) adminTarget(w http.ResponseWriter, r *http.Request) *users.User { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return nil + } + u, err := s.auth.st.GetByID(id) + if err != nil { + adminStoreFail(w, "load", id, err) + return nil + } + return u +} + +// guardRemoval blocks disabling/deleting/demoting yourself (except a +// self-demotion, allowed via allowSelf), config admins, and the last active +// admin. Writes 409 (or 500 if the admin count fails) and returns false +// when blocked. Count-then-mutate is not atomic; two admins removing each +// other concurrently can still both pass. +func (s *Server) guardRemoval(w http.ResponseWriter, actor, target *users.User, allowSelf bool) bool { + if target.ID == actor.ID && !allowSelf { + writeError(w, http.StatusConflict, "use My account to change your own account") + return false + } + if target.Role == users.RoleAdmin && s.auth.isConfigAdmin(target.Email) { + writeError(w, http.StatusConflict, "this admin is listed in adminEmails; remove them from the config first") + return false + } + if target.Role == users.RoleAdmin && target.Status == users.StatusActive { + n, err := s.auth.st.CountActiveAdmins() + if err != nil { + log.Printf("[users] admin guard: count admins for user #%d: %v", target.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return false + } + if n <= 1 { + writeError(w, http.StatusConflict, "this is the last admin; promote someone else first") + return false + } + } + return true +} + +func (s *Server) writeAdminRow(w http.ResponseWriter, id int64) { + u, err := s.auth.st.GetByID(id) + if err != nil { + adminStoreFail(w, "reload", id, err) + return + } + last, err := s.auth.st.LatestMailByUser() + if err != nil { + log.Printf("[users] admin latest mail: %v", err) + } + writeJSON(w, s.auth.adminRow(*u, last)) +} + +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request, _ *users.User, _ *users.Session) { + q := r.URL.Query() + f := users.ListFilter{Status: users.Status(q.Get("status")), Role: users.Role(q.Get("role")), Query: q.Get("q")} + switch f.Status { + case "", users.StatusPending, users.StatusActive, users.StatusDisabled: + default: + writeError(w, http.StatusBadRequest, "invalid status filter") + return + } + if f.Role != "" && !f.Role.Valid() { + writeError(w, http.StatusBadRequest, "invalid role filter") + return + } + list, err := s.auth.st.List(f) + if err != nil { + log.Printf("[users] admin list: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + last, err := s.auth.st.LatestMailByUser() + if err != nil { + log.Printf("[users] admin latest mail: %v", err) + } + out := make([]adminUserJSON, 0, len(list)) + for _, u := range list { + out = append(out, s.auth.adminRow(u, last)) + } + writeJSON(w, out) +} + +func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request, _ *users.User, _ *users.Session) { + u := s.adminTarget(w, r) + if u == nil { + return + } + a := s.auth + last, err := a.st.LatestMailByUser() + if err != nil { + log.Printf("[users] admin latest mail: %v", err) + } + d := adminUserDetailJSON{User: a.adminRow(*u, last), Sessions: []sessionJSON{}, Mail: []mailJSON{}, Audit: []auditJSON{}} + list, err := a.st.ListSessions(u.ID) + if err != nil { + adminStoreFail(w, "list sessions", u.ID, err) + return + } + for _, x := range list { + d.Sessions = append(d.Sessions, sessionToJSON(x, 0)) + } + mails, err := a.st.MailForUser(u.ID, 50) + if err != nil { + adminStoreFail(w, "list mail", u.ID, err) + return + } + for _, m := range mails { + d.Mail = append(d.Mail, mailToJSON(m)) + } + entries, err := a.st.AuditFor(u.ID, 100) + if err != nil { + adminStoreFail(w, "list audit", u.ID, err) + return + } + for _, e := range entries { + d.Audit = append(d.Audit, auditJSON{ID: e.ID, At: rfc3339(e.At), ActorUserID: e.ActorUserID, + Action: e.Action, TargetUserID: e.TargetUserID, Detail: e.Detail}) + } + writeJSON(w, d) +} + +func (s *Server) handleAdminDisable(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + t := s.adminTarget(w, r) + if t == nil || !s.guardRemoval(w, actor, t, false) { + return + } + if t.Status != users.StatusActive { + writeError(w, http.StatusConflict, "only active accounts can be disabled; delete or activate a pending account instead") + return + } + if err := s.auth.invalidateTokens("disable", t.ID, users.PurposeActivate, users.PurposeReset, users.PurposeEmailChange); err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err := s.auth.st.SetStatus(t.ID, users.StatusDisabled); err != nil { + adminStoreFail(w, "disable", t.ID, err) + return + } + if err := s.auth.st.DeleteUserSessions(t.ID, 0); err != nil { + // currentUser rejects non-active users, so the sessions are dead + // anyway; keep going but leave a trace. + log.Printf("[users] disable: delete sessions for user #%d: %v", t.ID, err) + } + s.auth.audit(idPtr(actor.ID), "user.disable", idPtr(t.ID), nil) + s.writeAdminRow(w, t.ID) +} + +func (s *Server) handleAdminEnable(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + t := s.adminTarget(w, r) + if t == nil { + return + } + if t.Status != users.StatusDisabled { + writeError(w, http.StatusConflict, "only disabled accounts can be enabled") + return + } + if err := s.auth.st.SetStatus(t.ID, users.StatusActive); err != nil { + adminStoreFail(w, "enable", t.ID, err) + return + } + s.auth.audit(idPtr(actor.ID), "user.enable", idPtr(t.ID), nil) + s.writeAdminRow(w, t.ID) +} + +func (s *Server) handleAdminDelete(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + t := s.adminTarget(w, r) + if t == nil || !s.guardRemoval(w, actor, t, false) { + return + } + if err := s.auth.st.Delete(t.ID); err != nil { + adminStoreFail(w, "delete", t.ID, err) + return + } + s.auth.audit(idPtr(actor.ID), "user.delete", idPtr(t.ID), nil) + writeJSON(w, okResponse{OK: true}) +} + +func (s *Server) handleAdminRole(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + t := s.adminTarget(w, r) + if t == nil { + return + } + var req roleRequest + if !decodeJSON(w, r, &req) { + return + } + if !req.Role.Valid() { + writeError(w, http.StatusBadRequest, "role must be user or admin") + return + } + // Activation sets the role from adminEmails, so a change now would be + // silently lost. + if t.Status == users.StatusPending { + writeError(w, http.StatusConflict, "activate the account first") + return + } + if req.Role == t.Role { + s.writeAdminRow(w, t.ID) + return + } + if req.Role == users.RoleUser && !s.guardRemoval(w, actor, t, true) { + return + } + if err := s.auth.st.SetRole(t.ID, req.Role); err != nil { + adminStoreFail(w, "set role", t.ID, err) + return + } + s.auth.audit(idPtr(actor.ID), "user.role", idPtr(t.ID), map[string]string{"role": string(req.Role)}) + s.writeAdminRow(w, t.ID) +} + +func (s *Server) handleAdminResendActivation(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + a := s.auth + t := s.adminTarget(w, r) + if t == nil { + return + } + if t.Status != users.StatusPending { + writeError(w, http.StatusConflict, "only pending accounts need activation") + return + } + if err := a.mailToken(r.Context(), t, users.PurposeActivate, 48*time.Hour, "", "activate", + func(tok string) mailer.Message { return a.activationMail(t, tok) }); err != nil { + writeError(w, http.StatusServiceUnavailable, msgMailFailed) + return + } + a.audit(idPtr(actor.ID), "user.activation.resend", idPtr(t.ID), nil) + s.writeAdminRow(w, t.ID) +} + +// handleAdminActivate activates a pending user without the mail link, for +// when mail keeps failing. The address stays unverified; activated_by and +// the audit row keep that visible. +func (s *Server) handleAdminActivate(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + a := s.auth + t := s.adminTarget(w, r) + if t == nil { + return + } + if t.Status != users.StatusPending { + writeError(w, http.StatusConflict, "only pending accounts can be activated") + return + } + if err := a.st.InvalidateTokens(t.ID, users.PurposeActivate); err != nil { + log.Printf("[users] manual activate: invalidate tokens for user #%d: %v", t.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err := a.st.Activate(t.ID, a.roleFor(t.Email), idPtr(actor.ID)); err != nil { + adminStoreFail(w, "activate", t.ID, err) + return + } + a.audit(idPtr(actor.ID), "user.activate.manual", idPtr(t.ID), nil) + s.writeAdminRow(w, t.ID) +} + +func (s *Server) handleAdminMailRefresh(w http.ResponseWriter, r *http.Request, actor *users.User, _ *users.Session) { + a := s.auth + t := s.adminTarget(w, r) + if t == nil { + return + } + mailID, err := strconv.ParseInt(mux.Vars(r)["mailId"], 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid mail id") + return + } + rec, err := a.st.MailByID(mailID) + if err != nil && !errors.Is(err, users.ErrNotFound) { + log.Printf("[users] admin mail refresh: load mail %d for user #%d: %v", mailID, t.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err != nil || rec.UserID == nil || *rec.UserID != t.ID { + writeError(w, http.StatusNotFound, "mail not found") + return + } + if rec.ProviderMessageID == "" { + writeError(w, http.StatusConflict, "this mail has no provider message id") + return + } + evs, err := a.mail.Events(r.Context(), rec.ProviderMessageID) + if err != nil { + log.Printf("[users] admin mail refresh: provider events for user #%d: %s", t.ID, redactAddrs(err)) + writeError(w, http.StatusBadGateway, "mail provider unavailable") + return + } + a.ingestMailEvents(evs) + a.audit(idPtr(actor.ID), "user.mail.refresh", idPtr(t.ID), nil) + rec, err = a.st.MailByID(mailID) + if err != nil { + log.Printf("[users] admin mail refresh: reload mail %d for user #%d: %v", mailID, t.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, mailToJSON(*rec)) +} diff --git a/cmd/server/admin_users_test.go b/cmd/server/admin_users_test.go new file mode 100644 index 00000000..f6f1d404 --- /dev/null +++ b/cmd/server/admin_users_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +// adminFixture: config admin "boss" plus regular user "uma". +func adminFixture(t *testing.T) (*authFixture, *client, *client) { + f := newAuthFixture(t, "boss@example.org") + return f, f.registerAndActivate(t, "boss@example.org", "Boss", pw), f.registerAndActivate(t, "uma@example.org", "Uma", pw) +} + +func userPath(id int64, suffix string) string { + return fmt.Sprintf("/api/admin/users/%d%s", id, suffix) +} + +func TestAdminUsersRequiresAdmin(t *testing.T) { + f, _, uma := adminFixture(t) + expectStatus(t, f.do("GET", "/api/admin/users", nil), 401) + expectStatus(t, f.do("GET", "/api/admin/users", nil, as(uma)), 403) +} + +func TestAdminListAndDetail(t *testing.T) { + f, boss, uma := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "pending@example.org", DisplayName: "Pen", Password: pw}) + w := f.do("GET", "/api/admin/users?status=pending", nil, as(boss)) + expectStatus(t, w, 200) + rows := decode[[]adminUserJSON](t, w) + if len(rows) != 1 || rows[0].Email != "pending@example.org" || rows[0].LastMail == nil || rows[0].LastMail.Purpose != "activate" { + t.Fatalf("pending rows = %+v", rows) + } + expectStatus(t, f.do("GET", "/api/admin/users?status=bogus", nil, as(boss)), 400) + w = f.do("GET", userPath(uma.me.ID, ""), nil, as(boss)) + expectStatus(t, w, 200) + d := decode[adminUserDetailJSON](t, w) + if d.User.Email != "uma@example.org" || len(d.Sessions) != 1 || len(d.Mail) != 1 || len(d.Audit) == 0 { + t.Fatalf("detail = %+v", d) + } + all := decode[[]adminUserJSON](t, f.do("GET", "/api/admin/users", nil, as(boss))) + for _, r := range all { + if r.Email == "boss@example.org" && !r.ConfigAdmin { + t.Fatal("config admin not marked") + } + } +} + +func TestAdminDisableEnable(t *testing.T) { + f, boss, uma := adminFixture(t) + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/disable"), nil, as(boss)), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(uma)), 401) + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "uma@example.org", Password: pw}), 401) + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/disable"), nil, as(boss)), 409) + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/enable"), nil, as(boss)), 200) + f.login(t, "uma@example.org", pw) +} + +func TestAdminGuards(t *testing.T) { + f, boss, uma := adminFixture(t) + expectStatus(t, f.do("POST", userPath(boss.me.ID, "/disable"), nil, as(boss)), 409) // self + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/role"), roleRequest{Role: users.RoleAdmin}, as(boss)), 200) + umaAdmin := f.login(t, "uma@example.org", pw) + expectStatus(t, f.do("POST", userPath(boss.me.ID, "/disable"), nil, as(umaAdmin)), 409) // config admin + expectStatus(t, f.do("DELETE", userPath(boss.me.ID, ""), nil, as(umaAdmin)), 409) // config admin + expectStatus(t, f.do("POST", userPath(boss.me.ID, "/role"), roleRequest{Role: users.RoleUser}, as(umaAdmin)), 409) // config admin + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/role"), roleRequest{Role: "root"}, as(boss)), 400) + // uma may demote herself: boss remains. + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/role"), roleRequest{Role: users.RoleUser}, as(umaAdmin)), 200) +} + +func TestAdminLastAdminCannotDemoteSelf(t *testing.T) { + f := newAuthFixture(t) + solo := f.registerAndActivate(t, "solo@example.org", "Solo", pw) + f.st.SetRole(solo.me.ID, users.RoleAdmin) // UI-promoted, not a config admin + expectStatus(t, f.do("POST", userPath(solo.me.ID, "/role"), roleRequest{Role: users.RoleUser}, as(solo)), 409) +} + +func TestAdminManualActivate(t *testing.T) { + f, boss, _ := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "late@example.org", DisplayName: "Late", Password: pw}) + link := f.lastToken(t) + late, _ := f.st.GetByEmail("late@example.org") + expectStatus(t, f.do("POST", userPath(late.ID, "/activate"), nil, as(boss)), 200) + got, _ := f.st.GetByID(late.ID) + if got.Status != users.StatusActive || got.ActivatedBy == nil || *got.ActivatedBy != boss.me.ID { + t.Fatalf("after manual activate: %+v", got) + } + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: link, Password: pw}), 410) + expectStatus(t, f.do("POST", userPath(late.ID, "/activate"), nil, as(boss)), 409) + if !hasAudit(t, f, late.ID, "user.activate.manual") { + t.Fatal("no user.activate.manual audit row") + } + f.login(t, "late@example.org", pw) +} + +func TestAdminResendActivation(t *testing.T) { + f, boss, uma := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "re@example.org", DisplayName: "Re", Password: pw}) + old := f.lastToken(t) + re, _ := f.st.GetByEmail("re@example.org") + expectStatus(t, f.do("POST", userPath(re.ID, "/resend-activation"), nil, as(boss)), 200) + fresh := f.lastToken(t) + if fresh == old { + t.Fatal("no new link sent") + } + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: old, Password: pw}), 410) + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: fresh, Password: pw}), 200) + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/resend-activation"), nil, as(boss)), 409) +} + +func TestAdminDelete(t *testing.T) { + f, boss, uma := adminFixture(t) + expectStatus(t, f.do("DELETE", userPath(uma.me.ID, ""), nil, as(boss)), 200) + expectStatus(t, f.do("GET", userPath(uma.me.ID, ""), nil, as(boss)), 404) +} + +func TestAdminMailRefresh(t *testing.T) { + f, boss, uma := adminFixture(t) + mails, _ := f.st.MailForUser(uma.me.ID, 10) + m := mails[0] + f.fake.SetEvents(m.ProviderMessageID, []mailer.Event{ + {MessageID: m.ProviderMessageID, Event: mailer.EventHardBounce, Reason: "user unknown", At: m.SentAt.Add(60e9)}, + }) + w := f.do("POST", userPath(uma.me.ID, fmt.Sprintf("/mail/%d/refresh", m.ID)), nil, as(boss)) + expectStatus(t, w, 200) + if got := decode[mailJSON](t, w); got.LastEvent != mailer.EventHardBounce || got.LastReason != "user unknown" { + t.Fatalf("refreshed = %+v", got) + } + if !hasAudit(t, f, uma.me.ID, "user.mail.refresh") { + t.Fatal("no user.mail.refresh audit row") + } + if u, _ := f.st.GetByID(uma.me.ID); !u.EmailBouncing { + t.Fatal("hard bounce did not flag the address") + } + expectStatus(t, f.do("POST", userPath(boss.me.ID, fmt.Sprintf("/mail/%d/refresh", m.ID)), nil, as(boss)), 404) // wrong owner +} + +func hasAudit(t *testing.T, f *authFixture, userID int64, action string) bool { + t.Helper() + entries, err := f.st.AuditFor(userID, 50) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Action == action { + return true + } + } + return false +} + +// A pending row squatting on a config-admin address is not an admin yet and +// stays deletable. +func TestAdminDeletePendingSquatterOnConfigAddress(t *testing.T) { + f, boss, _ := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "squat@example.org", DisplayName: "Sq", Password: pw}) + f.srv.auth.set.adminEmails["squat@example.org"] = true + sq, _ := f.st.GetByEmail("squat@example.org") + expectStatus(t, f.do("DELETE", userPath(sq.ID, ""), nil, as(boss)), 200) + if _, err := f.st.GetByID(sq.ID); err == nil { + t.Fatal("pending squatter still present") + } +} + +func TestAdminUnknownAndBadID(t *testing.T) { + f, boss, _ := adminFixture(t) + expectStatus(t, f.do("POST", userPath(9999, "/disable"), nil, as(boss)), 404) + expectStatus(t, f.do("GET", "/api/admin/users/abc", nil, as(boss)), 400) +} + +// A pending account must go through activation; disable then enable must not +// be a way around it. +func TestAdminCannotDisablePending(t *testing.T) { + f, boss, _ := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "pend@example.org", DisplayName: "Pe", Password: pw}) + p, _ := f.st.GetByEmail("pend@example.org") + expectStatus(t, f.do("POST", userPath(p.ID, "/disable"), nil, as(boss)), 409) + got, _ := f.st.GetByID(p.ID) + if got.Status != users.StatusPending { + t.Fatalf("status = %s, want pending", got.Status) + } + expectStatus(t, f.do("POST", userPath(p.ID, "/enable"), nil, as(boss)), 409) +} + +func TestAdminDisableKillsPendingLinks(t *testing.T) { + f, boss, uma := adminFixture(t) + confirm, reset := pendingLinks(t, f, uma, "uma@example.org", "attacker@example.org") + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/disable"), nil, as(boss)), 200) + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/enable"), nil, as(boss)), 200) + expectStatus(t, f.do("POST", "/api/account/confirm-email", tokenRequest{Token: confirm}), 410) + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: reset, Password: "another new secret"}), 410) +} + +func TestAdminDisableTokenStoreFailureIs500(t *testing.T) { + f, boss, uma := adminFixture(t) + f.breakTable(t, "tokens") + expectStatus(t, f.do("POST", userPath(uma.me.ID, "/disable"), nil, as(boss)), 500) +} + +func TestAdminRoleChangeOnPendingIs409(t *testing.T) { + f, boss, _ := adminFixture(t) + f.do("POST", "/api/auth/register", registerRequest{Email: "pend@example.org", DisplayName: "Pend", Password: pw}) + p, _ := f.st.GetByEmail("pend@example.org") + w := f.do("POST", userPath(p.ID, "/role"), roleRequest{Role: users.RoleAdmin}, as(boss)) + expectStatus(t, w, 409) + if !strings.Contains(w.Body.String(), "activate the account first") { + t.Fatalf("body = %s", w.Body.String()) + } + if got, _ := f.st.GetByID(p.ID); got.Role != users.RoleUser { + t.Fatalf("role changed on a pending user: %+v", got) + } +} diff --git a/cmd/server/auth_e2e.go b/cmd/server/auth_e2e.go new file mode 100644 index 00000000..223334fa --- /dev/null +++ b/cmd/server/auth_e2e.go @@ -0,0 +1,39 @@ +//go:build e2etest + +package main + +// E2E-only hooks, compiled only with -tags e2etest (CI's corescope-server-e2e): +// the "fake" mail provider and GET /__e2e/last-mail, which returns the newest +// fake mail so Playwright can follow its link. Never part of a release build. + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/mailer" +) + +type e2eMail struct { + To string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` +} + +func init() { + fakeMailerAllowed = true + e2eRoutes = func(s *Server, r *mux.Router) { + r.HandleFunc("/__e2e/last-mail", func(w http.ResponseWriter, _ *http.Request) { + f, ok := s.auth.mail.(*mailer.Fake) + if !ok { + writeError(w, http.StatusNotFound, "not using the fake mailer") + return + } + m, _, ok := f.Last() + if !ok { + writeError(w, http.StatusNotFound, "no mail sent yet") + return + } + writeJSON(w, e2eMail{To: m.To, Subject: m.Subject, Text: m.Text}) + }).Methods("GET") + } +} diff --git a/cmd/server/auth_e2e_off_test.go b/cmd/server/auth_e2e_off_test.go new file mode 100644 index 00000000..2ea5ce43 --- /dev/null +++ b/cmd/server/auth_e2e_off_test.go @@ -0,0 +1,14 @@ +//go:build !e2etest + +package main + +import "testing" + +// The e2e probe must never exist in a normal build. +func TestE2ELastMailAbsentInNormalBuild(t *testing.T) { + f := newAuthFixture(t) + expectStatus(t, f.do("GET", "/__e2e/last-mail", nil), 404) + if e2eRoutes != nil || fakeMailerAllowed { + t.Fatal("e2e hooks are set in a normal build") + } +} diff --git a/cmd/server/auth_e2e_on_test.go b/cmd/server/auth_e2e_on_test.go new file mode 100644 index 00000000..7d5ebcfb --- /dev/null +++ b/cmd/server/auth_e2e_on_test.go @@ -0,0 +1,17 @@ +//go:build e2etest + +package main + +import "testing" + +func TestE2ELastMailReturnsNewestFakeMail(t *testing.T) { + f := newAuthFixture(t) // registerAuthRoutes must add the hook itself + expectStatus(t, f.do("GET", "/__e2e/last-mail", nil), 404) + f.registerAndActivate(t, "e2e@example.test", "E2E", "correct horse battery") + w := f.do("GET", "/__e2e/last-mail", nil) + expectStatus(t, w, 200) + m := decode[e2eMail](t, w) + if m.To != "e2e@example.test" || m.Text == "" { + t.Fatalf("unexpected mail: %+v", m) + } +} diff --git a/cmd/server/auth_fixture_test.go b/cmd/server/auth_fixture_test.go new file mode 100644 index 00000000..556346cb --- /dev/null +++ b/cmd/server/auth_fixture_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +const ( + testBase = "https://scope.example.org" + testAPIKey = "test-secret-key-strong-enough" + testHook = "webhook-secret-0123456789" +) + +type authFixture struct { + srv *Server + router *mux.Router + fake *mailer.Fake + st *users.Store +} + +// client is a browser: its session cookie and CSRF token. +type client struct { + cookie *http.Cookie + csrf string + me meResponse +} + +func newTestAuthService(t *testing.T, adminEmails ...string) (*authService, *mailer.Fake) { + t.Helper() + set := &userMgmtSettings{ + dbPath: filepath.Join(t.TempDir(), "users.db"), adminEmails: map[string]bool{}, + sessionTTL: 30 * 24 * time.Hour, provider: "fake", + fromEmail: "noreply@example.org", fromName: "CoreScope", webhookSecret: testHook, + } + set.baseURL, _ = url.Parse(testBase) + set.secureCookie = true + for _, e := range adminEmails { + set.adminEmails[e] = true + } + st, err := users.Open(set.dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + fake := &mailer.Fake{} + return newAuthService(set, st, fake), fake +} + +// newAuthFixture builds a Server with auth on and only the auth routes. +func newAuthFixture(t *testing.T, adminEmails ...string) *authFixture { + t.Helper() + a, fake := newTestAuthService(t, adminEmails...) + srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats(), auth: a} + r := mux.NewRouter() + srv.registerAuthRoutes(r) + return &authFixture{srv: srv, router: r, fake: fake, st: a.st} +} + +type reqMod func(*http.Request) + +func as(c *client) reqMod { + return func(r *http.Request) { + if c.cookie != nil { + r.AddCookie(c.cookie) + } + if c.csrf != "" { + r.Header.Set(csrfHeader, c.csrf) + } + } +} + +func header(k, v string) reqMod { return func(r *http.Request) { r.Header.Set(k, v) } } +func fromIP(ip string) reqMod { return func(r *http.Request) { r.RemoteAddr = ip + ":5555" } } + +func (f *authFixture) do(method, path string, body any, mods ...reqMod) *httptest.ResponseRecorder { + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req := httptest.NewRequest(method, path, rd) + req.RemoteAddr = "203.0.113.10:5555" + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if !isSafeMethod(method) { + req.Header.Set("Origin", testBase) + } + for _, m := range mods { + m(req) + } + w := httptest.NewRecorder() + f.router.ServeHTTP(w, req) + return w +} + +func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("decode %T from %q: %v", v, w.Body.String(), err) + } + return v +} + +func expectStatus(t *testing.T, w *httptest.ResponseRecorder, code int) { + t.Helper() + if w.Code != code { + t.Fatalf("status = %d, want %d; body: %s", w.Code, code, w.Body.String()) + } +} + +var tokenRE = regexp.MustCompile(`token=([A-Za-z0-9_%\-]+)`) + +// lastToken extracts the token from the newest fake mail's link. +func (f *authFixture) lastToken(t *testing.T) string { + t.Helper() + m, _, ok := f.fake.Last() + if !ok { + t.Fatal("no mail was sent") + } + sm := tokenRE.FindStringSubmatch(m.Text) + if sm == nil { + t.Fatalf("no token in mail text: %q", m.Text) + } + tok, _ := url.QueryUnescape(sm[1]) + return tok +} + +func sessionFrom(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName && c.Value != "" { + return c + } + } + t.Fatalf("no %s cookie in response", sessionCookieName) + return nil +} + +// registerAndActivate runs the link flow and returns the logged-in client. +func (f *authFixture) registerAndActivate(t *testing.T, email, name, password string) *client { + t.Helper() + w := f.do("POST", "/api/auth/register", registerRequest{Email: email, DisplayName: name, Password: password}) + expectStatus(t, w, 200) + w = f.do("POST", "/api/auth/activate", activateRequest{Token: f.lastToken(t), Password: password}) + expectStatus(t, w, 200) + me := decode[meResponse](t, w) + return &client{cookie: sessionFrom(t, w), csrf: me.CSRFToken, me: me} +} + +func (f *authFixture) login(t *testing.T, email, password string) *client { + t.Helper() + w := f.do("POST", "/api/auth/login", loginRequest{Email: email, Password: password}) + expectStatus(t, w, 200) + me := decode[meResponse](t, w) + return &client{cookie: sessionFrom(t, w), csrf: me.CSRFToken, me: me} +} + +// breakTable renames a users.db table behind the store's back, so the next +// store call that touches it fails with a DB error (not ErrNotFound). +func (f *authFixture) breakTable(t *testing.T, table string) { + t.Helper() + db, err := sql.Open("sqlite", f.srv.auth.set.dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`ALTER TABLE ` + table + ` RENAME TO ` + table + `_broken`); err != nil { + t.Fatal(err) + } +} + +// unusedTokens counts uid's outstanding links of purpose p, read straight +// from users.db (the raw tokens are not observable when no mail left). +func (f *authFixture) unusedTokens(t *testing.T, uid int64, p users.Purpose) int { + t.Helper() + db, err := sql.Open("sqlite", f.srv.auth.set.dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM tokens WHERE user_id = ? AND purpose = ? AND used_at IS NULL`, uid, string(p)).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// execDB runs raw SQL on users.db behind the store's back (triggers that +// simulate a concurrent writer or a failing statement). +func (f *authFixture) execDB(t *testing.T, stmt string) { + t.Helper() + db, err := sql.Open("sqlite", f.srv.auth.set.dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(stmt); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/server/auth_flow_test.go b/cmd/server/auth_flow_test.go new file mode 100644 index 00000000..3776bd79 --- /dev/null +++ b/cmd/server/auth_flow_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/meshcore-analyzer/users" +) + +const pw = "correct horse battery" + +func TestAuthRegisterActivateLoginLogout(t *testing.T) { + f := newAuthFixture(t) + w := f.do("POST", "/api/auth/register", registerRequest{Email: "Alice@Example.org", DisplayName: "Alice", Password: pw}) + expectStatus(t, w, 200) + if m, _, _ := f.fake.Last(); m.To != "alice@example.org" || !strings.Contains(m.Subject, "Activate") { + t.Fatalf("activation mail = %+v", m) + } + // Pending accounts cannot log in. + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "alice@example.org", Password: pw}), 401) + + w = f.do("POST", "/api/auth/activate", activateRequest{Token: f.lastToken(t), Password: pw}) + expectStatus(t, w, 200) + me := decode[meResponse](t, w) + ck := sessionFrom(t, w) + if me.Role != users.RoleUser || me.CSRFToken == "" || !ck.HttpOnly || !ck.Secure { + t.Fatalf("activate: me=%+v cookie=%+v", me, ck) + } + c := &client{cookie: ck, csrf: me.CSRFToken} + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(c)), 200) + expectStatus(t, f.do("POST", "/api/auth/logout", nil, as(c)), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(c)), 401) + f.login(t, "alice@example.org", pw) +} + +func TestAuthRegisterIsEnumerationSafe(t *testing.T) { + f := newAuthFixture(t) + f.registerAndActivate(t, "bob@example.org", "Bob", pw) + first := f.do("POST", "/api/auth/register", registerRequest{Email: "carol@example.org", DisplayName: "Carol", Password: pw}) + again := f.do("POST", "/api/auth/register", registerRequest{Email: "bob@example.org", DisplayName: "Bobby", Password: pw}) + if first.Code != again.Code || first.Body.String() != again.Body.String() { + t.Fatalf("responses differ: %d %q vs %d %q", first.Code, first.Body, again.Code, again.Body) + } + if m, _, _ := f.fake.Last(); m.To != "bob@example.org" || !strings.Contains(m.Subject, "Registration attempt") { + t.Fatalf("existing-account notice = %+v", m) + } + // Re-registering a still-pending address re-sends the activation link. + f.do("POST", "/api/auth/register", registerRequest{Email: "carol@example.org", DisplayName: "Carol", Password: pw}) + if m, _, _ := f.fake.Last(); m.To != "carol@example.org" || !strings.Contains(m.Subject, "Activate") { + t.Fatalf("pending re-register mail = %+v", m) + } +} + +func TestAuthValidationErrors(t *testing.T) { + f := newAuthFixture(t) + w := f.do("POST", "/api/auth/register", registerRequest{Email: "x@example.org", DisplayName: "X", Password: pw}) + expectStatus(t, w, 400) + w = f.do("POST", "/api/auth/register", registerRequest{Email: "x@example.org", DisplayName: "Xx", Password: "short"}) + expectStatus(t, w, 400) + w = f.do("POST", "/api/auth/register", map[string]string{"email": "x@example.org", "bogus": "1"}) + expectStatus(t, w, 400) +} + +func TestAuthActivateAdminFromConfig(t *testing.T) { + f := newAuthFixture(t, "boss@example.org") + c := f.registerAndActivate(t, "boss@example.org", "Boss", pw) + if c.me.Role != users.RoleAdmin { + t.Fatalf("config admin activated as %q", c.me.Role) + } + // Activation links are single-use. + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: "bogus", Password: pw}), 410) +} + +func TestAuthLoginGenericFailure(t *testing.T) { + f := newAuthFixture(t) + f.registerAndActivate(t, "dave@example.org", "Dave", pw) + unknown := f.do("POST", "/api/auth/login", loginRequest{Email: "nobody@example.org", Password: pw}) + wrong := f.do("POST", "/api/auth/login", loginRequest{Email: "dave@example.org", Password: "wrong password!"}) + if unknown.Code != 401 || wrong.Code != 401 || unknown.Body.String() != wrong.Body.String() { + t.Fatalf("unknown=%d %q wrong=%d %q", unknown.Code, unknown.Body, wrong.Code, wrong.Body) + } +} + +func TestAuthConfigAdminRoleRestoredOnLogin(t *testing.T) { + f := newAuthFixture(t, "boss@example.org") + c := f.registerAndActivate(t, "boss@example.org", "Boss", pw) + f.st.SetRole(c.me.ID, users.RoleUser) // e.g. demoted by direct DB edit + if again := f.login(t, "boss@example.org", pw); again.me.Role != users.RoleAdmin { + t.Fatalf("config admin logged in as %q", again.me.Role) + } +} + +func TestAuthForgotAndReset(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "erin@example.org", "Erin", pw) + sent := len(f.fake.Sent()) + expectStatus(t, f.do("POST", "/api/auth/forgot", emailRequest{Email: "nobody@example.org"}), 200) + if len(f.fake.Sent()) != sent { + t.Fatal("forgot for an unknown address sent mail") + } + expectStatus(t, f.do("POST", "/api/auth/forgot", emailRequest{Email: "erin@example.org"}), 200) + tok := f.lastToken(t) + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: tok, Password: "a brand new secret"}), 200) + expectStatus(t, f.do("GET", "/api/auth/me", nil, as(c)), 401) // all sessions ended + f.login(t, "erin@example.org", "a brand new secret") + expectStatus(t, f.do("POST", "/api/auth/reset", resetRequest{Token: tok, Password: "another new secret"}), 410) +} + +func TestAuthForgotIsEnumerationSafe(t *testing.T) { + f := newAuthFixture(t) + f.registerAndActivate(t, "gina@example.org", "Gina", pw) + unknown := f.do("POST", "/api/auth/forgot", emailRequest{Email: "nobody@example.org"}) + known := f.do("POST", "/api/auth/forgot", emailRequest{Email: "gina@example.org"}) + if unknown.Code != known.Code || unknown.Body.String() != known.Body.String() { + t.Fatalf("responses differ: %d %q vs %d %q", unknown.Code, unknown.Body, known.Code, known.Body) + } +} + +func TestAuthWrongCSRFTokenRejected(t *testing.T) { + f := newAuthFixture(t) + c := f.registerAndActivate(t, "hank@example.org", "Hank", pw) + f.router.HandleFunc("/api/test/mutate", f.srv.withUser(func(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) { + writeJSON(w, okResponse{OK: true}) + })).Methods("POST") + expectStatus(t, f.do("POST", "/api/test/mutate", nil, as(c)), 200) + expectStatus(t, f.do("POST", "/api/test/mutate", nil, as(c), header(csrfHeader, "not-the-token")), 403) +} + +func TestAuthLoginRateLimit(t *testing.T) { + f := newAuthFixture(t) + for i := 0; i < 10; i++ { + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "x@example.org", Password: "nope nope nope"}), 401) + } + w := f.do("POST", "/api/auth/login", loginRequest{Email: "x@example.org", Password: "nope nope nope"}) + expectStatus(t, w, 429) + if w.Header().Get("Retry-After") == "" { + t.Fatal("429 without Retry-After") + } + // Per-address bucket: another IP is still limited for the same address. + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "x@example.org", Password: "nope"}, fromIP("198.51.100.7")), 429) +} + +func TestAuthOriginRequiredForLogin(t *testing.T) { + f := newAuthFixture(t) + w := f.do("POST", "/api/auth/login", loginRequest{Email: "x@example.org", Password: pw}, header("Origin", "https://evil.example")) + expectStatus(t, w, 403) +} + +func TestAuthMailFailureRollsBackRegistration(t *testing.T) { + f := newAuthFixture(t) + f.fake.SetSendErr(errors.New("brevo down")) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "fay@example.org", DisplayName: "Fay", Password: pw}), 503) + if _, err := f.st.GetByEmail("fay@example.org"); !errors.Is(err, users.ErrNotFound) { + t.Fatalf("user kept after failed mail: %v", err) + } + f.fake.SetSendErr(nil) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "fay@example.org", DisplayName: "Fay", Password: pw}), 200) +} + +func TestAuthPendingReRegisterNewestWins(t *testing.T) { + f := newAuthFixture(t) + const p1, p2 = "first password here", "second password here" + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "vic@example.org", DisplayName: "Squatter", Password: p1}), 200) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "vic@example.org", DisplayName: "Victim", Password: p2}), 200) + link := f.lastToken(t) + // The squatter cannot use the victim's link: it needs the newest password. + w := f.do("POST", "/api/auth/activate", activateRequest{Token: link, Password: p1}) + expectStatus(t, w, 401) + w = f.do("POST", "/api/auth/activate", activateRequest{Token: link, Password: p2}) + expectStatus(t, w, 200) + if me := decode[meResponse](t, w); me.DisplayName != "Victim" { + t.Fatalf("displayName = %q, want the newest registration's", me.DisplayName) + } + expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "vic@example.org", Password: p1}), 401) + f.login(t, "vic@example.org", p2) +} + +func TestAuthActivateWrongPasswordKeepsToken(t *testing.T) { + f := newAuthFixture(t) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "kim@example.org", DisplayName: "Kim", Password: pw}), 200) + tok := f.lastToken(t) + w := f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: "not the password"}) + expectStatus(t, w, 401) + if !strings.Contains(w.Body.String(), "wrong password for this account") { + t.Fatalf("body = %s", w.Body.String()) + } + if u, _ := f.st.GetByEmail("kim@example.org"); u.Status != users.StatusPending { + t.Fatalf("activated with a wrong password: %+v", u) + } + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: pw}), 200) +} + +func TestAuthActivatePasswordRateLimited(t *testing.T) { + f := newAuthFixture(t) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "lou@example.org", DisplayName: "Lou", Password: pw}), 200) + tok := f.lastToken(t) + for i := 0; i < 10; i++ { + ip := fromIP(fmt.Sprintf("198.51.100.%d", i+1)) + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: "guess guess guess"}, ip), 401) + } + // Per-account bucket: a fresh IP is still limited for this account. + w := f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: pw}, fromIP("198.51.100.99")) + expectStatus(t, w, 429) + if w.Header().Get("Retry-After") == "" { + t.Fatal("429 without Retry-After") + } +} + +func TestAuthActivateDBErrorIs500(t *testing.T) { + f := newAuthFixture(t) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "max@example.org", DisplayName: "Max", Password: pw}), 200) + tok := f.lastToken(t) + f.breakTable(t, "users") + expectStatus(t, f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: pw}), 500) +} + +// A re-register that lands between the password check and the activation +// replaces the hash; the activation must not go through on the old one. +func TestAuthActivateHashChangedMidwayIs409(t *testing.T) { + f := newAuthFixture(t) + expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "ned@example.org", DisplayName: "Ned", Password: pw}), 200) + tok := f.lastToken(t) + f.execDB(t, `CREATE TRIGGER race AFTER UPDATE OF used_at ON tokens BEGIN + UPDATE users SET password_hash = 'replaced-by-a-re-register' WHERE id = NEW.user_id; END`) + w := f.do("POST", "/api/auth/activate", activateRequest{Token: tok, Password: pw}) + expectStatus(t, w, 409) + if !strings.Contains(w.Body.String(), "account changed, try again") { + t.Fatalf("body = %s", w.Body.String()) + } + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName && c.Value != "" { + t.Fatal("session cookie set on a refused activation") + } + } + u, _ := f.st.GetByEmail("ned@example.org") + if u.Status != users.StatusPending { + t.Fatalf("activated on a replaced hash: %+v", u) + } + if n := f.unusedTokens(t, u.ID, users.PurposeActivate); n != 1 { + t.Fatalf("%d usable activation links after a refused activation, want 1", n) + } +} diff --git a/cmd/server/auth_handlers.go b/cmd/server/auth_handlers.go new file mode 100644 index 00000000..c34a80ca --- /dev/null +++ b/cmd/server/auth_handlers.go @@ -0,0 +1,283 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +const ( + msgCheckMail = "If the address can receive mail, a message is on its way. Check your inbox." + msgBadLogin = "incorrect email or password" + msgMailFailed = "mail could not be sent, try again later" +) + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req registerRequest + if !decodeJSON(w, r, &req) { + return + } + email, err := users.NormalizeEmail(req.Email) + if writeValidation(w, err) { + return + } + name, err := users.ValidateDisplayName(req.DisplayName) + if writeValidation(w, err) { + return + } + if writeValidation(w, users.ValidatePassword(req.Password)) { + return + } + if !a.allow(w, r, a.signup, "email:"+email) { + return + } + // Hash before the lookup so new and existing addresses cost the same. + hash, err := users.HashPassword(req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if existing, err := a.st.GetByEmail(email); err == nil { + // Identical response either way (no account enumeration). + if existing.Status == users.StatusPending { + // Newest registration wins: the account takes the password and + // name of the latest request, and activation demands that + // password, so a squatter cannot plant credentials and the owner + // can recover a squatted address. Failures stay invisible to the + // client (same response as every other branch). + if err := a.st.SetPassword(existing.ID, hash); err != nil { + log.Printf("[users] register: update pending user #%d: %v", existing.ID, err) + } else if err := a.st.SetDisplayName(existing.ID, name); err != nil { + log.Printf("[users] register: update pending user #%d: %v", existing.ID, err) + } else { + existing.DisplayName = name + _ = a.mailToken(r.Context(), existing, users.PurposeActivate, 48*time.Hour, "", "activate", + func(tok string) mailer.Message { return a.activationMail(existing, tok) }) + } + } else { + _ = a.sendMail(r.Context(), existing, "register-notice", a.registerNoticeMail(existing)) + } + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) + return + } + u, err := a.st.CreatePending(email, name, hash) + if errors.Is(err, users.ErrEmailTaken) { // lost a race with a concurrent register + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + err = a.mailToken(r.Context(), u, users.PurposeActivate, 48*time.Hour, "", "activate", + func(tok string) mailer.Message { return a.activationMail(u, tok) }) + if err != nil { + if derr := a.st.Delete(u.ID); derr != nil { + log.Printf("[users] rollback of user #%d failed: %v", u.ID, derr) + } + writeError(w, http.StatusServiceUnavailable, msgMailFailed) + return + } + a.audit(nil, "user.register", idPtr(u.ID), nil) + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) +} + +// handleActivate needs the token and the account password: a re-register +// of a pending address replaces the password, so whoever clicks must prove +// they know the newest one (a squatter's link cannot log the owner into an +// account carrying the squatter's password). +func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req activateRequest + if !decodeJSON(w, r, &req) { + return + } + uid, err := a.st.TokenUser(req.Token, users.PurposeActivate) + if err != nil { + writeTokenError(w, err) + return + } + if !a.allow(w, r, a.login, "activate:#"+strconv.FormatInt(uid, 10)) { + return + } + u, err := a.st.GetByID(uid) + if err != nil && !errors.Is(err, users.ErrNotFound) { + log.Printf("[users] activate: load user #%d: %v", uid, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if err != nil || u.Status != users.StatusPending { + writeError(w, http.StatusGone, "this account is already activated, log in instead") + return + } + ok, err := users.VerifyPassword(u.PasswordHash, req.Password) + if err != nil { + log.Printf("[users] activate: verify password for user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if !ok { + writeError(w, http.StatusUnauthorized, "wrong password for this account") + return + } + // Activate only on the hash that was just verified: a re-register in + // between replaces it, and the newest password must be proven. + switch err := a.st.ActivateWithToken(req.Token, u.ID, a.roleFor(u.Email), u.PasswordHash); { + case err == nil: + case errors.Is(err, users.ErrAccountChanged): + writeError(w, http.StatusConflict, "account changed, try again") + return + case errors.Is(err, users.ErrTokenInvalid), errors.Is(err, users.ErrTokenExpired): + writeTokenError(w, err) + return + default: + log.Printf("[users] activate user #%d: %v", u.ID, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(nil, "user.activate", idPtr(u.ID), nil) + if u, err = a.st.GetByID(u.ID); err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.startSession(w, r, u) +} + +// startSession creates a session, sets the cookie and answers with /me. +func (a *authService) startSession(w http.ResponseWriter, r *http.Request, u *users.User) { + raw, sess, err := a.st.CreateSession(u.ID, a.set.sessionTTL, r.UserAgent()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + _ = a.st.TouchLogin(u.ID) + a.setSessionCookie(w, raw, sess.ExpiresAt) + writeJSON(w, meFrom(u, sess)) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req loginRequest + if !decodeJSON(w, r, &req) { + return + } + email, emailErr := users.NormalizeEmail(req.Email) + key := "email:" + email + if emailErr != nil { + key = "email:invalid" + } + if !a.allow(w, r, a.login, key) { + return + } + var u *users.User + if emailErr == nil { + u, _ = a.st.GetByEmail(email) + } + if u == nil { + users.BurnPasswordCheck(req.Password) + writeError(w, http.StatusUnauthorized, msgBadLogin) + return + } + ok, err := users.VerifyPassword(u.PasswordHash, req.Password) + if err != nil || !ok || u.Status != users.StatusActive { + writeError(w, http.StatusUnauthorized, msgBadLogin) + return + } + // Config wins: an address in adminEmails is always admin. + if a.isConfigAdmin(u.Email) && u.Role != users.RoleAdmin { + if err := a.st.SetRole(u.ID, users.RoleAdmin); err == nil { + u.Role = users.RoleAdmin + a.audit(nil, "user.role.config", idPtr(u.ID), map[string]string{"role": "admin"}) + } + } + a.startSession(w, r, u) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { + _ = s.auth.st.DeleteSessionByToken(c.Value) + } + s.auth.clearSessionCookie(w) + writeJSON(w, okResponse{OK: true}) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + u, sess := s.auth.currentUser(w, r) + if u == nil { + writeError(w, http.StatusUnauthorized, "not logged in") + return + } + writeJSON(w, meFrom(u, sess)) +} + +func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req emailRequest + if !decodeJSON(w, r, &req) { + return + } + email, err := users.NormalizeEmail(req.Email) + if err != nil { + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) + return + } + if !a.allow(w, r, a.signup, "email:"+email) { + return + } + u, err := a.st.GetByEmail(email) + if err != nil || u.Status != users.StatusActive { + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) + return + } + err = a.mailToken(r.Context(), u, users.PurposeReset, time.Hour, "", "reset", + func(tok string) mailer.Message { return a.resetMail(u, tok) }) + if err != nil { + writeError(w, http.StatusServiceUnavailable, msgMailFailed) + return + } + writeJSON(w, okResponse{OK: true, Message: msgCheckMail}) +} + +func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { + a := s.auth + var req resetRequest + if !decodeJSON(w, r, &req) { + return + } + if writeValidation(w, users.ValidatePassword(req.Password)) { + return + } + uid, _, err := a.st.ConsumeToken(req.Token, users.PurposeReset) + if err != nil { + writeTokenError(w, err) + return + } + hash, err := users.HashPassword(req.Password) + if err == nil { + err = a.st.SetPassword(uid, hash) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + // Sessions first: a token-store failure below must not leave an + // attacker's session alive. + if err := a.st.DeleteUserSessions(uid, 0); err != nil { + log.Printf("[users] reset: end sessions for user #%d: %v", uid, err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + // A pending email change must not survive the owner's recovery. + if err := a.invalidateTokens("reset", uid, users.PurposeEmailChange); err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + a.audit(idPtr(uid), "user.password.reset", idPtr(uid), nil) + writeJSON(w, okResponse{OK: true, Message: "Password changed. Log in with your new password."}) +} diff --git a/cmd/server/auth_mail.go b/cmd/server/auth_mail.go new file mode 100644 index 00000000..5b48dc99 --- /dev/null +++ b/cmd/server/auth_mail.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "html" + "log" + "net/url" + "regexp" + "strings" + "time" + + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +type mailContent struct { + subject string + greeting string + paragraphs []string + actionLabel string + actionURL string +} + +// link builds {publicBaseUrl}/#/account/{page}?token=… (never from the +// request Host, see the spec's Configuration section). +func (a *authService) link(page, token string) string { + return a.set.baseURL.String() + "/#/account/" + page + "?token=" + url.QueryEscape(token) +} + +func (a *authService) render(to, toName, tag string, c mailContent) mailer.Message { + footer := "You received this because this address was used on " + a.set.baseURL.Host + + ". If that was not you, you can ignore this mail." + var text, h strings.Builder + text.WriteString(c.greeting + "\n\n") + h.WriteString("

" + html.EscapeString(c.greeting) + "

") + for _, p := range c.paragraphs { + text.WriteString(p + "\n\n") + h.WriteString("

" + html.EscapeString(p) + "

") + } + if c.actionURL != "" { + text.WriteString(c.actionLabel + ":\n" + c.actionURL + "\n\n") + h.WriteString(`

` + html.EscapeString(c.actionLabel) + `

`) + } + text.WriteString(footer + "\n") + h.WriteString(`

` + html.EscapeString(footer) + `

`) + return mailer.Message{To: to, ToName: toName, Subject: "[" + a.set.fromName + "] " + c.subject, + HTML: h.String(), Text: text.String(), Tag: tag} +} + +func (a *authService) activationMail(u *users.User, token string) mailer.Message { + return a.render(u.Email, u.DisplayName, "activate", mailContent{ + subject: "Activate your account", greeting: "Hello " + u.DisplayName + ",", + paragraphs: []string{"Confirm your address to activate your account. The link works once and expires in 48 hours.", + "When you open the link, enter the password you chose when you registered."}, + actionLabel: "Activate my account", actionURL: a.link("activate", token)}) +} + +func (a *authService) resetMail(u *users.User, token string) mailer.Message { + return a.render(u.Email, u.DisplayName, "reset", mailContent{ + subject: "Reset your password", greeting: "Hello " + u.DisplayName + ",", + paragraphs: []string{"Someone asked to reset the password of your account. The link works once and expires in 1 hour. Using it logs out all your devices."}, + actionLabel: "Choose a new password", actionURL: a.link("reset", token)}) +} + +func (a *authService) registerNoticeMail(u *users.User) mailer.Message { + return a.render(u.Email, u.DisplayName, "register-notice", mailContent{ + subject: "Registration attempt with your address", greeting: "Hello " + u.DisplayName + ",", + paragraphs: []string{"Someone tried to register a new account with your address, but you already have one.", + "If it was you, log in, or use \"forgot password\" if you lost it."}, + actionLabel: "Log in", actionURL: a.set.baseURL.String() + "/#/account/login"}) +} + +func (a *authService) emailChangeConfirmMail(u *users.User, newEmail, token string) mailer.Message { + return a.render(newEmail, u.DisplayName, "email-change", mailContent{ + subject: "Confirm your new address", greeting: "Hello " + u.DisplayName + ",", + paragraphs: []string{"Confirm that this address should replace the one on your account. The link expires in 24 hours."}, + actionLabel: "Confirm new address", actionURL: a.link("confirm-email", token)}) +} + +func (a *authService) emailChangeNoticeMail(u *users.User, newEmail string) mailer.Message { + return a.render(u.Email, u.DisplayName, "email-change-notice", mailContent{ + subject: "Address change requested", greeting: "Hello " + u.DisplayName + ",", + paragraphs: []string{"A change of your account address to " + newEmail + " was requested. It takes effect only when confirmed from the new address.", + "If this was not you, change your password now."}}) +} + +// addrRE matches anything around an @ up to whitespace or a character that +// cannot appear unquoted in an address, so RFC 5322 atext specials and +// UTF-8 local parts and domains are covered. +var addrRE = regexp.MustCompile(`[^\s"<>()\[\],;:]+@[^\s"<>()\[\],;:]+`) + +// redactAddrs renders a mailer/provider error for the server log with every +// email address replaced by : provider messages can echo the +// recipient, and addresses belong only in the audit and mail tables. +func redactAddrs(err error) string { + return addrRE.ReplaceAllString(err.Error(), "") +} + +// sendMail sends msg and records it in the mail log. The error is logged +// (without tokens) and returned so the caller can answer 503. +func (a *authService) sendMail(ctx context.Context, u *users.User, purpose string, msg mailer.Message) error { + id, err := a.mail.Send(ctx, msg) + if err != nil { + log.Printf("[users] mail %s for user #%d failed: %s", purpose, u.ID, redactAddrs(err)) + return err + } + if _, err := a.st.LogMail(idPtr(u.ID), msg.To, purpose, id); err != nil { + log.Printf("[users] mail log for user #%d: %v", u.ID, err) + } + return nil +} + +// ingestMailEvents records provider events and flags undeliverable addresses. +// Shared by the webhook and the admin "refresh" pull. +func (a *authService) ingestMailEvents(evs []mailer.Event) { + for _, ev := range evs { + uid, found, err := a.st.RecordMailEvent(ev.MessageID, ev.Event, ev.At, ev.Reason) + if err != nil { + log.Printf("[users] record mail event: %v", err) + continue + } + if found && uid != nil && mailer.IsUndeliverable(ev.Event) { + if err := a.st.SetEmailBouncing(*uid, true); err != nil { + log.Printf("[users] flag bouncing for user #%d: %v", *uid, err) + } + } + } +} + +// mailToken issues a one-time token for u, builds the mail around it and +// sends it. If issuing or sending fails the purpose's outstanding tokens are +// invalidated, so no usable link exists for a mail that never left; the +// caller answers 503. newEmail is stored only for users.PurposeEmailChange. +// mailPurpose is the label recorded in the mail log. +func (a *authService) mailToken(ctx context.Context, u *users.User, tokenPurpose users.Purpose, + ttl time.Duration, newEmail, mailPurpose string, build func(token string) mailer.Message) error { + tok, err := a.st.IssueToken(u.ID, tokenPurpose, ttl, newEmail) + if err != nil { + log.Printf("[users] issue %s token for user #%d: %v", tokenPurpose, u.ID, err) + } else { + err = a.sendMail(ctx, u, mailPurpose, build(tok)) + } + if err != nil { + if ierr := a.st.InvalidateTokens(u.ID, tokenPurpose); ierr != nil { + log.Printf("[users] invalidate %s tokens for user #%d: %v", tokenPurpose, u.ID, ierr) + } + return err + } + return nil +} diff --git a/cmd/server/auth_mail_test.go b/cmd/server/auth_mail_test.go new file mode 100644 index 00000000..dad95fe2 --- /dev/null +++ b/cmd/server/auth_mail_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log" + "strings" + "testing" + + "github.com/meshcore-analyzer/users" +) + +func TestRedactAddrs(t *testing.T) { + cases := map[string]string{ + `brevo 400 invalid_parameter: email "Bob.Smith+x@Mail.Example.org" is not valid`: `brevo 400 invalid_parameter: email "" is not valid`, + "to a@b.co and c_d@e-f.example.net failed": "to and failed", + "brevo 503 service unavailable": "brevo 503 service unavailable", + "rejected john.smith&co@example.org": "rejected ", + "rejected jörg.müller@x.de": "rejected ", + "rejected bob@bücher.de": "rejected ", + "rejected bob+newsletter@example.org": "rejected ", + "rejected o'neil!#$*/=?^{|}~@example.org, retry": "rejected , retry", + "recipient ; cc (bea@example.org)": "recipient <>; cc ()", + } + for in, want := range cases { + if got := redactAddrs(errors.New(in)); got != want { + t.Errorf("redactAddrs(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSendMailLogHasNoAddress(t *testing.T) { + a, fake := newTestAuthService(t) + fake.SetSendErr(errors.New(`brevo 400: invalid email victim@example.org`)) + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + u := &users.User{ID: 7, Email: "victim@example.org", DisplayName: "Vic"} + if err := a.sendMail(context.Background(), u, "reset", a.resetMail(u, "tok")); err == nil { + t.Fatal("sendMail returned nil on a send error") + } + out := buf.String() + if strings.Contains(out, "victim@example.org") || !strings.Contains(out, "") || !strings.Contains(out, "#7") { + t.Fatalf("log line = %q", out) + } +} + +// Activation needs the account password (handleActivate), so the mail says so. +func TestActivationMailMentionsPassword(t *testing.T) { + a, _ := newTestAuthService(t) + u := &users.User{ID: 1, Email: "new@example.org", DisplayName: "New"} + m := a.activationMail(u, "tok") + for _, body := range []string{m.Text, m.HTML} { + if !strings.Contains(body, "enter the password you chose") { + t.Fatalf("activation mail does not mention the password: %q", body) + } + } +} diff --git a/cmd/server/auth_ratelimit.go b/cmd/server/auth_ratelimit.go new file mode 100644 index 00000000..ca36a6f5 --- /dev/null +++ b/cmd/server/auth_ratelimit.go @@ -0,0 +1,107 @@ +package main + +import ( + "log" + "math" + "net/http" + "strconv" + "sync" + "time" +) + +// rateLimiter is a keyed token bucket: n requests per period, refilled +// continuously. In-memory, so limits reset on restart. That is acceptable +// for brute-force damping, not a quota system. +type rateLimiter struct { + mu sync.Mutex + rate float64 // tokens per second + burst float64 + buckets map[string]*rlBucket + now func() time.Time + + maxBuckets int // cap on len(buckets); a field so tests can lower it + warnFull sync.Once +} + +const rateLimiterMaxBuckets = 100000 + +type rlBucket struct { + tokens float64 + last time.Time +} + +func newRateLimiter(n int, per time.Duration) *rateLimiter { + return &rateLimiter{rate: float64(n) / per.Seconds(), burst: float64(n), + buckets: map[string]*rlBucket{}, now: time.Now, maxBuckets: rateLimiterMaxBuckets} +} + +// take consumes one token for key. When none is left it reports how long +// until the next one. +func (l *rateLimiter) take(key string) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + b := l.buckets[key] + if b == nil { + if len(l.buckets) >= l.maxBuckets { + l.sweepLocked(now) + if len(l.buckets) >= l.maxBuckets { + l.warnFull.Do(func() { + log.Printf("[users] rate limiter full (%d keys), refusing new keys until buckets refill", l.maxBuckets) + }) + return false, time.Duration(1 / l.rate * float64(time.Second)) + } + } + b = &rlBucket{tokens: l.burst, last: now} + l.buckets[key] = b + } + b.tokens = math.Min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate) + b.last = now + if b.tokens >= 1 { + b.tokens-- + return true, 0 + } + return false, time.Duration((1 - b.tokens) / l.rate * float64(time.Second)) +} + +// gc drops buckets that have refilled completely (they carry no state). +func (l *rateLimiter) gc() { + l.mu.Lock() + defer l.mu.Unlock() + l.sweepLocked(l.now()) +} + +// sweepLocked deletes fully refilled buckets. The caller holds l.mu. +func (l *rateLimiter) sweepLocked(now time.Time) { + for k, b := range l.buckets { + if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst { + delete(l.buckets, k) + } + } +} + +// allow applies l to the client IP (only when IPs can be told apart; same +// rule as the /ws limiter, see ws_limits.go) and to each extra key. It +// writes 429 + Retry-After and returns false when any bucket is empty. +func (a *authService) allow(w http.ResponseWriter, r *http.Request, l *rateLimiter, extraKeys ...string) bool { + keys := extraKeys + if ip, distinct := a.ipr.clientIP(r); distinct && ip != nil { + keys = append([]string{"ip:" + ip.String()}, extraKeys...) + } else { + a.warnIndistinct.Do(func() { + log.Printf("[users] rate limits by IP are off: requests arrive from a proxy and userManagement.trustedProxies is empty; per-address limits still apply, and the webhook limiter is off too") + }) + } + for _, k := range keys { + if ok, wait := l.take(k); !ok { + secs := int(math.Ceil(wait.Seconds())) + if secs < 1 { + secs = 1 + } + w.Header().Set("Retry-After", strconv.Itoa(secs)) + writeError(w, http.StatusTooManyRequests, "too many attempts, try again later") + return false + } + } + return true +} diff --git a/cmd/server/auth_ratelimit_test.go b/cmd/server/auth_ratelimit_test.go new file mode 100644 index 00000000..6e7db12e --- /dev/null +++ b/cmd/server/auth_ratelimit_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/meshcore-analyzer/users" +) + +func TestRateLimiterBurstAndRefill(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + l := newRateLimiter(3, time.Minute) + l.now = func() time.Time { return now } + for i := 0; i < 3; i++ { + if ok, _ := l.take("k"); !ok { + t.Fatalf("take %d refused within burst", i) + } + } + ok, wait := l.take("k") + if ok || wait <= 0 || wait > 20*time.Second { + t.Fatalf("4th take = %v, wait %v", ok, wait) + } + if ok, _ := l.take("other"); !ok { + t.Fatal("keys are not independent") + } + now = now.Add(21 * time.Second) // one token per 20s + if ok, _ := l.take("k"); !ok { + t.Fatal("no refill after 21s") + } + now = now.Add(time.Hour) + l.gc() + l.mu.Lock() + n := len(l.buckets) + l.mu.Unlock() + if n != 0 { + t.Fatalf("gc kept %d full buckets", n) + } +} + +func TestRateLimiterBucketCap(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + l := newRateLimiter(3, time.Minute) + l.now = func() time.Time { return now } + l.maxBuckets = 2 + l.take("a") + l.take("b") + // both buckets are partly drained, so the inline sweep frees nothing + ok, wait := l.take("c") + if ok || wait <= 0 { + t.Fatalf("new key at the cap = %v, wait %v; want refused", ok, wait) + } + if ok, _ := l.take("a"); !ok { + t.Fatal("existing key refused at the cap") + } + // once the old buckets have refilled, the sweep makes room + now = now.Add(time.Hour) + if ok, _ := l.take("c"); !ok { + t.Fatal("new key refused after buckets refilled") + } + l.mu.Lock() + n := len(l.buckets) + l.mu.Unlock() + if n > 2 { + t.Fatalf("buckets = %d, cap 2", n) + } +} + +func TestAllowKeysOnForwardedIP(t *testing.T) { + a, _ := newTestAuthService(t) + a.ipr.trustedProxies = parseCIDRList([]string{"10.0.0.0/8"}, "test") + l := newRateLimiter(1, time.Hour) + try := func(xff string) bool { + r := httptest.NewRequest("POST", "/x", nil) + r.RemoteAddr = "10.0.0.1:4000" + r.Header.Set("X-Forwarded-For", xff) + return a.allow(httptest.NewRecorder(), r, l) + } + if !try("198.51.100.7") { + t.Fatal("first request refused") + } + if try("198.51.100.7") { + t.Fatal("same forwarded IP not limited") + } + if !try("198.51.100.8") { + t.Fatal("different forwarded IP shares a bucket") + } +} + +func TestCloseUserManagementWaitsForJanitor(t *testing.T) { + a, _ := newTestAuthService(t) + srv := &Server{auth: a} + release := make(chan struct{}) + a.wg.Add(1) + go func() { + defer a.wg.Done() + <-a.stop + <-release // janitor still busy after stop was signalled + }() + done := make(chan struct{}) + go func() { srv.closeUserManagement(); close(done) }() + select { + case <-done: + t.Fatal("close returned while the janitor was still running") + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("close did not return after the janitor exited") + } +} + +func TestAdminRowConfigAdminNeedsAdminRole(t *testing.T) { + a, _ := newTestAuthService(t, "boss@example.org") + pending := users.User{Email: "boss@example.org", Role: users.RoleUser, Status: users.StatusPending} + if a.adminRow(pending, nil).ConfigAdmin { + t.Fatal("pending squatter on a config address reported as config admin") + } + admin := users.User{Email: "boss@example.org", Role: users.RoleAdmin, Status: users.StatusActive} + if !a.adminRow(admin, nil).ConfigAdmin { + t.Fatal("real config admin not reported") + } +} diff --git a/cmd/server/auth_routes.go b/cmd/server/auth_routes.go new file mode 100644 index 00000000..33082b57 --- /dev/null +++ b/cmd/server/auth_routes.go @@ -0,0 +1,43 @@ +package main + +import "github.com/gorilla/mux" + +// e2eRoutes is set only by the e2etest build (auth_e2e.go). +var e2eRoutes func(s *Server, r *mux.Router) + +// registerAuthRoutes adds every user-management route. Called by +// RegisterRoutes only when s.auth != nil, so with the feature off these +// paths are not registered and fall through to the SPA handler like any +// unknown path. +func (s *Server) registerAuthRoutes(r *mux.Router) { + r.HandleFunc("/api/auth/register", s.requireOrigin(s.handleRegister)).Methods("POST") + r.HandleFunc("/api/auth/activate", s.requireOrigin(s.handleActivate)).Methods("POST") + r.HandleFunc("/api/auth/login", s.requireOrigin(s.handleLogin)).Methods("POST") + r.HandleFunc("/api/auth/logout", s.requireOrigin(s.handleLogout)).Methods("POST") + r.HandleFunc("/api/auth/me", s.handleMe).Methods("GET") + r.HandleFunc("/api/auth/forgot", s.requireOrigin(s.handleForgot)).Methods("POST") + r.HandleFunc("/api/auth/reset", s.requireOrigin(s.handleReset)).Methods("POST") + r.HandleFunc("/api/account", s.withUser(s.handleAccountPatch)).Methods("PATCH") + r.HandleFunc("/api/account", s.withUser(s.handleAccountDelete)).Methods("DELETE") + r.HandleFunc("/api/account/password", s.withUser(s.handleAccountPassword)).Methods("POST") + r.HandleFunc("/api/account/email", s.withUser(s.handleAccountEmail)).Methods("POST") + r.HandleFunc("/api/account/confirm-email", s.requireOrigin(s.handleConfirmEmail)).Methods("POST") + r.HandleFunc("/api/account/sessions", s.withUser(s.handleAccountSessions)).Methods("GET") + r.HandleFunc("/api/account/sessions/{id}", s.withUser(s.handleAccountSessionDelete)).Methods("DELETE") + r.HandleFunc("/api/admin/users", s.withAdmin(s.handleAdminUsers)).Methods("GET") + r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminUserDetail)).Methods("GET") + r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminDelete)).Methods("DELETE") + r.HandleFunc("/api/admin/users/{id}/disable", s.withAdmin(s.handleAdminDisable)).Methods("POST") + r.HandleFunc("/api/admin/users/{id}/enable", s.withAdmin(s.handleAdminEnable)).Methods("POST") + r.HandleFunc("/api/admin/users/{id}/role", s.withAdmin(s.handleAdminRole)).Methods("POST") + r.HandleFunc("/api/admin/users/{id}/resend-activation", s.withAdmin(s.handleAdminResendActivation)).Methods("POST") + r.HandleFunc("/api/admin/users/{id}/activate", s.withAdmin(s.handleAdminActivate)).Methods("POST") + r.HandleFunc("/api/admin/users/{id}/mail/{mailId}/refresh", s.withAdmin(s.handleAdminMailRefresh)).Methods("POST") + // The webhook exists only when a secret is configured. + if s.auth.set.webhookSecret != "" { + r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST") + } + if e2eRoutes != nil { + e2eRoutes(s, r) + } +} diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go new file mode 100644 index 00000000..4afbef61 --- /dev/null +++ b/cmd/server/auth_service.go @@ -0,0 +1,161 @@ +package main + +import ( + "log" + "os" + "sync" + "time" + + "github.com/meshcore-analyzer/mailer" + "github.com/meshcore-analyzer/users" +) + +// authService is the optional user-management layer. Server.auth is nil +// unless userManagement.enabled, and nothing in this file runs then. +type authService struct { + st *users.Store + mail mailer.Mailer + set *userMgmtSettings + ipr *wsLimiter // only its clientIP rule is used + + login *rateLimiter + signup *rateLimiter // register, forgot, self-service resend + hook *rateLimiter + + warnIndistinct sync.Once + stop chan struct{} + stopOnce sync.Once + wg sync.WaitGroup // the janitor +} + +func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *authService { + return &authService{ + st: st, mail: m, set: set, + ipr: &wsLimiter{trustedProxies: set.trustedProxies}, + login: newRateLimiter(10, 15*time.Minute), + signup: newRateLimiter(5, time.Hour), + hook: newRateLimiter(600, time.Minute), + stop: make(chan struct{}), + } +} + +// initUserManagement builds s.auth when the feature is on. Call it after +// NewServer and before RegisterRoutes. measurementDBPath is passed to +// users.Open as a forbidden path, so users.db can never be the analyzer DB. +func (s *Server) initUserManagement(measurementDBPath string) error { + if !s.cfg.UserManagementEnabled() { + return nil + } + set, err := resolveUserManagement(s.cfg.UserManagement, measurementDBPath, os.Getenv) + if err != nil { + return err + } + st, err := users.Open(set.dbPath, measurementDBPath) + if err != nil { + return err + } + var m mailer.Mailer + if set.provider == "fake" { + m = &mailer.Fake{} + } else { + m = mailer.NewBrevo(set.brevoAPIKey, set.fromEmail, set.fromName) + } + s.auth = newAuthService(set, st, m) + s.auth.logStartup() + s.auth.wg.Add(1) + go func() { + defer s.auth.wg.Done() + s.auth.janitor(time.Hour) + }() + return nil +} + +// closeUserManagement stops the janitor and closes users.db. Safe when off. +func (s *Server) closeUserManagement() { + if s.auth == nil { + return + } + s.auth.stopOnce.Do(func() { + close(s.auth.stop) + s.auth.wg.Wait() + if err := s.auth.st.Close(); err != nil { + log.Printf("[users] close: %v", err) + } + }) +} + +func (a *authService) logStartup() { + log.Printf("[users] user management enabled: db=%s, %d config admin(s), webhook=%v", + a.set.dbPath, len(a.set.adminEmails), a.set.webhookSecret != "") + admins, err := a.st.List(users.ListFilter{Role: users.RoleAdmin}) + if err != nil { + log.Printf("[users] list admins: %v", err) + return + } + for _, u := range admins { + if !a.isConfigAdmin(u.Email) { + log.Printf("[users] note: admin #%d is not in adminEmails (promoted in the UI, or removed from config)", u.ID) + } + } +} + +func (a *authService) janitor(every time.Duration) { + t := time.NewTicker(every) + defer t.Stop() + for { + a.prune() + select { + case <-a.stop: + return + case <-t.C: + } + } +} + +func (a *authService) prune() { + if _, err := a.st.PruneStalePending(48 * time.Hour); err != nil { + log.Printf("[users] prune pending: %v", err) + } + if _, err := a.st.PruneExpiredSessions(); err != nil { + log.Printf("[users] prune sessions: %v", err) + } + if _, err := a.st.PruneTokens(7 * 24 * time.Hour); err != nil { + log.Printf("[users] prune tokens: %v", err) + } + if _, err := a.st.PruneMail(90 * 24 * time.Hour); err != nil { + log.Printf("[users] prune mail log: %v", err) + } + a.login.gc() + a.signup.gc() + a.hook.gc() +} + +func (a *authService) isConfigAdmin(email string) bool { return a.set.adminEmails[email] } + +// roleFor is the role an address gets on activation. +func (a *authService) roleFor(email string) users.Role { + if a.isConfigAdmin(email) { + return users.RoleAdmin + } + return users.RoleUser +} + +func (a *authService) audit(actor *int64, action string, target *int64, detail map[string]string) { + if err := a.st.Audit(actor, action, target, detail); err != nil { + log.Printf("[users] audit %s: %v", action, err) + } +} + +func idPtr(id int64) *int64 { return &id } + +// invalidateTokens burns uid's outstanding links of each purpose. A failure +// is logged (user id only) and returned; callers answer 500. +func (a *authService) invalidateTokens(op string, uid int64, ps ...users.Purpose) error { + for _, p := range ps { + if err := a.st.InvalidateTokens(uid, p); err != nil { + log.Printf("[users] %s: invalidate %s tokens for user #%d: %v", op, p, uid, err) + return err + } + } + return nil +} diff --git a/cmd/server/auth_session.go b/cmd/server/auth_session.go new file mode 100644 index 00000000..ce8f2187 --- /dev/null +++ b/cmd/server/auth_session.go @@ -0,0 +1,132 @@ +package main + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/meshcore-analyzer/users" +) + +const ( + sessionCookieName = "cs_session" + csrfHeader = "X-CS-CSRF" +) + +func (a *authService) setSessionCookie(w http.ResponseWriter, raw string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: raw, Path: "/", + Expires: expires, MaxAge: int(time.Until(expires).Seconds()), + HttpOnly: true, Secure: a.set.secureCookie, SameSite: http.SameSiteLaxMode, + }) +} + +func (a *authService) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: a.set.secureCookie, SameSite: http.SameSiteLaxMode}) +} + +// currentUser returns the active user behind the session cookie, or nils. +// A session last seen more than a day ago is extended (sliding expiry) +// and its cookie re-issued. +func (a *authService) currentUser(w http.ResponseWriter, r *http.Request) (*users.User, *users.Session) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return nil, nil + } + sess, err := a.st.LookupSession(c.Value) + if err != nil { + return nil, nil + } + u, err := a.st.GetByID(sess.UserID) + if err != nil || u.Status != users.StatusActive { + return nil, nil + } + if time.Since(sess.LastSeenAt) > 24*time.Hour { + if err := a.st.ExtendSession(sess.ID, a.set.sessionTTL); err == nil { + a.setSessionCookie(w, c.Value, time.Now().Add(a.set.sessionTTL)) + } + } + return u, sess +} + +// originOK requires the request's Origin (or, if absent, Referer) to be the +// configured publicBaseUrl's origin. +func (a *authService) originOK(r *http.Request) bool { + want := a.set.baseURL.Scheme + "://" + a.set.baseURL.Host + if o := r.Header.Get("Origin"); o != "" { + return strings.EqualFold(o, want) + } + if ref := r.Header.Get("Referer"); ref != "" { + u, err := url.Parse(ref) + return err == nil && strings.EqualFold(u.Scheme+"://"+u.Host, want) + } + return false +} + +func (a *authService) csrfOK(r *http.Request, sess *users.Session) bool { + return a.originOK(r) && constantTimeEqual(r.Header.Get(csrfHeader), sess.CSRFToken) +} + +func isSafeMethod(m string) bool { return m == http.MethodGet || m == http.MethodHead } + +type authedHandler func(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) + +// withUser requires a logged-in active user; state-changing methods must +// also pass the origin + CSRF-token check. +func (s *Server) withUser(h authedHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, sess := s.auth.currentUser(w, r) + if u == nil { + writeError(w, http.StatusUnauthorized, "not logged in") + return + } + if !isSafeMethod(r.Method) && !s.auth.csrfOK(r, sess) { + writeError(w, http.StatusForbidden, "CSRF check failed") + return + } + h(w, r, u, sess) + } +} + +// adminSessionOK is the one place that decides whether a session user may +// act as admin: state-changing methods must pass the origin + CSRF check, +// and the role must be admin. It writes the 403 itself when not. +func (a *authService) adminSessionOK(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) bool { + if !isSafeMethod(r.Method) && !a.csrfOK(r, sess) { + writeError(w, http.StatusForbidden, "CSRF check failed") + return false + } + if u.Role != users.RoleAdmin { + writeError(w, http.StatusForbidden, "admin role required") + return false + } + return true +} + +// withAdmin requires a logged-in admin (see adminSessionOK). +func (s *Server) withAdmin(h authedHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, sess := s.auth.currentUser(w, r) + if u == nil { + writeError(w, http.StatusUnauthorized, "not logged in") + return + } + if !s.auth.adminSessionOK(w, r, u, sess) { + return + } + h(w, r, u, sess) + } +} + +// requireOrigin guards unauthenticated state-changing endpoints (login CSRF). +func (s *Server) requireOrigin(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.auth.originOK(r) { + writeError(w, http.StatusForbidden, "request origin not allowed") + return + } + h(w, r) + } +} diff --git a/cmd/server/auth_types.go b/cmd/server/auth_types.go new file mode 100644 index 00000000..23d1ee28 --- /dev/null +++ b/cmd/server/auth_types.go @@ -0,0 +1,206 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/meshcore-analyzer/users" +) + +type okResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +type meResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role users.Role `json:"role"` + CSRFToken string `json:"csrfToken"` +} + +type registerRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type emailRequest struct { + Email string `json:"email"` +} + +type tokenRequest struct { + Token string `json:"token"` +} + +type activateRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + +type resetRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + +type profileRequest struct { + DisplayName string `json:"displayName"` +} + +type passwordChangeRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +type emailChangeRequest struct { + NewEmail string `json:"newEmail"` + CurrentPassword string `json:"currentPassword"` +} + +type passwordConfirmRequest struct { + CurrentPassword string `json:"currentPassword"` +} + +type roleRequest struct { + Role users.Role `json:"role"` +} + +type sessionJSON struct { + ID int64 `json:"id"` + CreatedAt string `json:"createdAt"` + LastSeenAt string `json:"lastSeenAt"` + ExpiresAt string `json:"expiresAt"` + UserAgent string `json:"userAgent"` + Current bool `json:"current"` +} + +type mailEventJSON struct { + Event string `json:"event"` + At string `json:"at"` + Reason string `json:"reason,omitempty"` +} + +type mailJSON struct { + ID int64 `json:"id"` + Purpose string `json:"purpose"` + To string `json:"to"` + SentAt string `json:"sentAt"` + LastEvent string `json:"lastEvent"` + LastEventAt string `json:"lastEventAt"` + LastReason string `json:"lastReason,omitempty"` + Events []mailEventJSON `json:"events,omitempty"` +} + +type adminUserJSON struct { + ID int64 `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role users.Role `json:"role"` + Status users.Status `json:"status"` + CreatedAt string `json:"createdAt"` + ActivatedAt *string `json:"activatedAt"` + ActivatedManually bool `json:"activatedManually"` + ActivatedBy *int64 `json:"activatedBy"` + LastLoginAt *string `json:"lastLoginAt"` + EmailBouncing bool `json:"emailBouncing"` + ConfigAdmin bool `json:"configAdmin"` + LastMail *mailJSON `json:"lastMail"` +} + +type auditJSON struct { + ID int64 `json:"id"` + At string `json:"at"` + ActorUserID *int64 `json:"actorUserId"` + Action string `json:"action"` + TargetUserID *int64 `json:"targetUserId"` + Detail map[string]string `json:"detail"` +} + +type adminUserDetailJSON struct { + User adminUserJSON `json:"user"` + Sessions []sessionJSON `json:"sessions"` + Mail []mailJSON `json:"mail"` + Audit []auditJSON `json:"audit"` +} + +func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +func rfc3339Ptr(t *time.Time) *string { + if t == nil { + return nil + } + s := rfc3339(*t) + return &s +} + +func meFrom(u *users.User, sess *users.Session) meResponse { + return meResponse{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, CSRFToken: sess.CSRFToken} +} + +func sessionToJSON(s users.Session, currentID int64) sessionJSON { + return sessionJSON{ID: s.ID, CreatedAt: rfc3339(s.CreatedAt), LastSeenAt: rfc3339(s.LastSeenAt), + ExpiresAt: rfc3339(s.ExpiresAt), UserAgent: s.UserAgent, Current: s.ID == currentID} +} + +func mailToJSON(m users.MailRecord) mailJSON { + out := mailJSON{ID: m.ID, Purpose: m.Purpose, To: m.ToEmail, SentAt: rfc3339(m.SentAt), + LastEvent: m.LastEvent, LastEventAt: rfc3339(m.LastEventAt), LastReason: m.LastReason} + for _, e := range m.Events { + out.Events = append(out.Events, mailEventJSON{Event: e.Event, At: rfc3339(e.At), Reason: e.Reason}) + } + return out +} + +func (a *authService) adminRow(u users.User, last map[int64]users.MailRecord) adminUserJSON { + row := adminUserJSON{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, Status: u.Status, + CreatedAt: rfc3339(u.CreatedAt), ActivatedAt: rfc3339Ptr(u.ActivatedAt), ActivatedManually: u.ActivatedBy != nil, + ActivatedBy: u.ActivatedBy, LastLoginAt: rfc3339Ptr(u.LastLoginAt), EmailBouncing: u.EmailBouncing, + ConfigAdmin: u.Role == users.RoleAdmin && a.isConfigAdmin(u.Email)} + if m, ok := last[u.ID]; ok { + mj := mailToJSON(m) + row.LastMail = &mj + } + return row +} + +// decodeJSON reads a small JSON body into dst, rejecting unknown fields. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return false + } + return true +} + +// writeValidation writes 400 for a users.ValidationError and reports +// whether it did. +func writeValidation(w http.ResponseWriter, err error) bool { + var ve *users.ValidationError + if errors.As(err, &ve) { + writeError(w, http.StatusBadRequest, ve.Msg) + return true + } + return false +} + +func writeTokenError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, users.ErrTokenExpired): + writeError(w, http.StatusGone, "this link has expired") + case errors.Is(err, users.ErrTokenInvalid): + writeError(w, http.StatusGone, "this link is invalid or was already used") + default: + writeError(w, http.StatusInternalServerError, "internal error") + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 591a9a55..bb5c4353 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -180,6 +180,11 @@ type Config struct { // off; see ClientRfSamplesEnabled. ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"` + // UserManagement gates optional accounts + // (docs/specs/2026-10-06-user-management-design.md). Absent/nil means off; + // see UserManagementEnabled. + UserManagement *UserManagementConfig `json:"userManagement,omitempty"` + ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"` NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"` diff --git a/cmd/server/go.mod b/cmd/server/go.mod index 7518c678..a9955e08 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -44,3 +44,26 @@ require ( ) replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue + +require ( + github.com/meshcore-analyzer/mailer v0.0.0-00010101000000-000000000000 + github.com/meshcore-analyzer/users v0.0.0 +) + +replace github.com/meshcore-analyzer/users => ../../internal/users + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + 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/crypto v0.31.0 // indirect + golang.org/x/sys v0.28.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.34.5 // indirect +) + +replace github.com/meshcore-analyzer/mailer => ../../internal/mailer diff --git a/cmd/server/go.sum b/cmd/server/go.sum index 8c505bf8..74e587a2 100644 --- a/cmd/server/go.sum +++ b/cmd/server/go.sum @@ -1,8 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +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/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/cmd/server/mail_webhook.go b/cmd/server/mail_webhook.go new file mode 100644 index 00000000..6f7cedc3 --- /dev/null +++ b/cmd/server/mail_webhook.go @@ -0,0 +1,39 @@ +package main + +import ( + "io" + "log" + "net/http" + + "github.com/meshcore-analyzer/mailer" +) + +// handleBrevoWebhook ingests Brevo transactional events. Brevo is configured +// with auth {"type":"bearer","token":}, so it sends +// "Authorization: Bearer ". Unknown message ids get 200 so Brevo +// does not retry forever. +func (s *Server) handleBrevoWebhook(w http.ResponseWriter, r *http.Request) { + a := s.auth + if !a.allow(w, r, a.hook) { + return + } + if !constantTimeEqual(r.Header.Get("Authorization"), "Bearer "+a.set.webhookSecret) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10)) + if err != nil { + writeError(w, http.StatusBadRequest, "body too large") + return + } + evs, err := mailer.ParseBrevoWebhook(body) + if err != nil { + // Authenticated but unusable: answer 200 so Brevo does not retry + // forever; log for the operator. + log.Printf("[users] brevo webhook: ignored payload: %s", redactAddrs(err)) + writeJSON(w, okResponse{OK: true}) + return + } + a.ingestMailEvents(evs) + writeJSON(w, okResponse{OK: true}) +} diff --git a/cmd/server/mail_webhook_test.go b/cmd/server/mail_webhook_test.go new file mode 100644 index 00000000..de6a3c01 --- /dev/null +++ b/cmd/server/mail_webhook_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "fmt" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func TestBrevoWebhookAuthAndIngest(t *testing.T) { + f, _, uma := adminFixture(t) + mails, _ := f.st.MailForUser(uma.me.ID, 10) + m := mails[0] + body := fmt.Sprintf(`{"event":"hard_bounce","email":"uma@example.org","message-id":%q,"ts_event":%d,"reason":"mailbox unavailable"}`, + m.ProviderMessageID, m.SentAt.Unix()+30) + post := func(auth, b string) int { + req := httptest.NewRequest("POST", "/api/mail/brevo/webhook", bytes.NewBufferString(b)) + req.RemoteAddr = "203.0.113.50:443" + if auth != "" { + req.Header.Set("Authorization", auth) + } + w := httptest.NewRecorder() + f.router.ServeHTTP(w, req) + return w.Code + } + if c := post("", body); c != 401 { + t.Fatalf("no auth = %d", c) + } + if c := post("Bearer wrong-secret-xxxxxxxx", body); c != 401 { + t.Fatalf("wrong auth = %d", c) + } + if c := post("Bearer "+testHook, "garbage"); c != 200 { // authenticated junk: 200 so Brevo stops retrying + t.Fatalf("garbage = %d", c) + } + if c := post("Bearer "+testHook, body); c != 200 { + t.Fatalf("valid = %d", c) + } + rec, _ := f.st.MailByID(m.ID) + if rec.LastEvent != "hard_bounce" || rec.LastReason != "mailbox unavailable" { + t.Fatalf("mail record = %+v", rec) + } + if u, _ := f.st.GetByID(uma.me.ID); !u.EmailBouncing { + t.Fatal("bounce flag not set") + } + unknown := `{"event":"delivered","message-id":"","ts_event":1}` + if c := post("Bearer "+testHook, unknown); c != 200 { + t.Fatalf("unknown id = %d (must be 200 so Brevo stops retrying)", c) + } +} + +func TestBrevoWebhookAbsentWithoutSecret(t *testing.T) { + f, _, _ := adminFixture(t) + f.srv.auth.set.webhookSecret = "" + r := newAuthFixtureRouterOnly(f) + req := httptest.NewRequest("POST", "/api/mail/brevo/webhook", bytes.NewBufferString("{}")) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 404 { + t.Fatalf("no secret configured = %d, want 404", w.Code) + } +} + +func newAuthFixtureRouterOnly(f *authFixture) *mux.Router { + r := mux.NewRouter() + f.srv.registerAuthRoutes(r) + return r +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 81718c7f..58a67982 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -367,6 +367,11 @@ func main() { srv := NewServer(database, cfg, hub) srv.configDir = configDir srv.store = store + // Optional user management (off by default). Fails startup on a bad + // config rather than running with registration that cannot work. + if err := srv.initUserManagement(resolvedDB); err != nil { + log.Fatalf("[users] %v", err) + } router := mux.NewRouter() srv.RegisterRoutes(router) @@ -544,6 +549,9 @@ func main() { // 3. Close WebSocket hub hub.Close() + // 3b. Close users.db (user management, opt-in; no-op when off). + srv.closeUserManagement() + // 4. Close database (release SQLite WAL lock) if err := dbClose(); err != nil { log.Printf("[server] DB close error: %v", err) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 09aaad61..c9ef7749 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -12,10 +12,15 @@ import ( // routeMeta holds metadata for a single API route. type routeMeta struct { - Summary string `json:"summary"` - Description string `json:"description,omitempty"` - Tag string `json:"tag"` - Auth bool `json:"auth,omitempty"` + Summary string `json:"summary"` + Description string `json:"description,omitempty"` + Tag string `json:"tag"` + Auth bool `json:"auth,omitempty"` + // Session marks routes that also accept the user-management session + // cookie (optional feature). With Auth too, either credential works. + // Set on the 3 documented requireAdmin routes (Auth: true) and on the + // user-management routes; other requireAdmin routes are undocumented. + Session bool `json:"session,omitempty"` QueryParams []paramMeta `json:"queryParams,omitempty"` // Response, when non-nil, is the OpenAPI schema object for the 200 // application/json response body. Routes without it fall back to the @@ -48,10 +53,36 @@ func routeDescriptions() map[string]routeMeta { "GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"}, "GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"}, "GET /api/mqtt/status": {Summary: "MQTT source status", Description: "Returns per-MQTT-source connection state and counters (lastConnectUnix, lastPacketUnix, packetsTotal, etc.). Broker URL passwords are masked. Sourced from the ingestor stats file; empty list when unavailable. (#1043)", Tag: "admin"}, - "POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true}, + "POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true, Session: true}, // "POST /api/admin/prune" removed in #1283 (ingestor owns prune). - "GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true}, - "GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-.db.", Tag: "admin", Auth: true}, + "GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true, Session: true}, + "GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-.db.", Tag: "admin", Auth: true, Session: true}, + + // User management (optional; routes exist only when userManagement.enabled) + "POST /api/auth/register": {Summary: "Register an account", Description: "Creates a pending account and mails an activation link. The response is identical whether or not the address is already registered.", Tag: "users"}, + "POST /api/auth/activate": {Summary: "Activate an account", Description: "Request body {token, password}: the mailed activation token and the account password chosen at (the newest) registration. A wrong password answers 401 and leaves the token usable; attempts are rate-limited per account. On success consumes the token, activates the account and starts a session.", Tag: "users"}, + "POST /api/auth/login": {Summary: "Log in", Description: "Email + password. Sets the cs_session cookie. Rate-limited per IP and per address.", Tag: "users"}, + "POST /api/auth/logout": {Summary: "Log out", Tag: "users"}, + "GET /api/auth/me": {Summary: "Current user", Description: "Returns the logged-in user and the CSRF token, or 401.", Tag: "users", Session: true}, + "POST /api/auth/forgot": {Summary: "Request a password reset", Tag: "users"}, + "POST /api/auth/reset": {Summary: "Reset the password", Description: "Consumes the mailed reset token and ends all sessions of the user.", Tag: "users"}, + "PATCH /api/account": {Summary: "Update profile", Tag: "users", Session: true}, + "DELETE /api/account": {Summary: "Delete own account", Tag: "users", Session: true}, + "POST /api/account/password": {Summary: "Change password", Tag: "users", Session: true}, + "POST /api/account/email": {Summary: "Request an address change", Tag: "users", Session: true}, + "POST /api/account/confirm-email": {Summary: "Confirm an address change", Tag: "users"}, + "GET /api/account/sessions": {Summary: "List own sessions", Tag: "users", Session: true}, + "DELETE /api/account/sessions/{id}": {Summary: "Revoke one own session", Tag: "users", Session: true}, + "GET /api/admin/users": {Summary: "List users (admin)", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "status", Description: "pending | active | disabled", Type: "string"}, {Name: "role", Description: "user | admin", Type: "string"}, {Name: "q", Description: "Substring of email or display name", Type: "string"}}}, + "GET /api/admin/users/{id}": {Summary: "User detail with sessions, mail log and audit (admin)", Tag: "users", Session: true}, + "DELETE /api/admin/users/{id}": {Summary: "Delete a user (admin)", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/disable": {Summary: "Disable a user (admin)", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/enable": {Summary: "Enable a user (admin)", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/role": {Summary: "Change a user's role (admin)", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/resend-activation": {Summary: "Resend the activation mail (admin)", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/activate": {Summary: "Activate a pending user manually (admin)", Description: "For when mail keeps failing. The address stays unverified; recorded as activatedBy + audit row.", Tag: "users", Session: true}, + "POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true}, + "POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer . Registered only when the secret is set.", Tag: "users"}, // Packets "GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets", @@ -366,10 +397,15 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} op["tags"] = []string{meta.Tag} tagSet[meta.Tag] = true } + var security []map[string][]string if meta.Auth { - op["security"] = []map[string]interface{}{ - {"ApiKeyAuth": []string{}}, - } + security = append(security, map[string][]string{"ApiKeyAuth": {}}) + } + if meta.Session { + security = append(security, map[string][]string{"CookieAuth": {}}) + } + if len(security) > 0 { + op["security"] = security } // Add query parameters @@ -415,7 +451,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} } // Build tags array (sorted) - tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets"} + tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets", "users"} tagDescriptions := map[string]string{ "admin": "Server administration and diagnostics", "analytics": "Network analytics and statistics", @@ -424,6 +460,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} "nodes": "Mesh node operations", "observers": "Packet observer/gateway operations", "packets": "Packet capture and decoding", + "users": "Optional user management (accounts, sessions, admin)", } var tags []interface{} for _, t := range tagOrder { @@ -454,6 +491,12 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} "in": "header", "name": "X-API-Key", }, + "CookieAuth": map[string]string{ + "type": "apiKey", + "in": "cookie", + "name": "cs_session", + "description": "User-management session (only when userManagement.enabled). Unsafe methods also need the X-CS-CSRF header from GET /api/auth/me.", + }, }, "schemas": componentSchemas(), }, diff --git a/cmd/server/readonly_invariant_test.go b/cmd/server/readonly_invariant_test.go index 0f671a2a..3e265655 100644 --- a/cmd/server/readonly_invariant_test.go +++ b/cmd/server/readonly_invariant_test.go @@ -248,3 +248,32 @@ func TestOpenDBRefusesMissingDatabase(t *testing.T) { t.Errorf("OpenDB created %q; a read-only open must not write anything", e.Name()) } } + +// TestUsersOpenIsTheOnlyServerWritePath pins the single exception to the +// read-only invariant (user management, opt-in): cmd/server may open a +// writable store only via users.Open, exactly once, and always passing the +// measurement DB path as forbidden. +func TestUsersOpenIsTheOnlyServerWritePath(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + call := regexp.MustCompile(`users\.Open\(`) + want := regexp.MustCompile(`users\.Open\(set\.dbPath, measurementDBPath\)`) + calls, good := 0, 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + calls += len(call.FindAll(src, -1)) + good += len(want.FindAll(src, -1)) + } + if calls != 1 || good != 1 { + t.Fatalf("users.Open calls in cmd/server: %d total, %d with the measurement DB forbidden; want exactly 1 and 1", calls, good) + } +} diff --git a/cmd/server/require_admin_test.go b/cmd/server/require_admin_test.go new file mode 100644 index 00000000..51d710bb --- /dev/null +++ b/cmd/server/require_admin_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequireAdminKeyOrAdminSession(t *testing.T) { + f, boss, uma := adminFixture(t) + p := "/api/test/admin-only" + f.router.Handle(p, f.srv.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, okResponse{OK: true}) + }))).Methods("GET", "POST") + + expectStatus(t, f.do("GET", p, nil), 401) // no credentials: the API-key gate answers + expectStatus(t, f.do("GET", p, nil, header("X-API-Key", testAPIKey)), 200) + expectStatus(t, f.do("GET", p, nil, as(uma)), 403) // user session + expectStatus(t, f.do("GET", p, nil, as(boss)), 200) // admin session, safe method + expectStatus(t, f.do("POST", p, nil, as(&client{cookie: boss.cookie})), 403) // no CSRF token + expectStatus(t, f.do("POST", p, nil, as(boss)), 200) + // A request carrying X-API-Key is judged on the key alone. + expectStatus(t, f.do("GET", p, nil, as(boss), header("X-API-Key", "wrong-key-wrong-key")), 401) +} + +func TestRequireAdminWithoutUserManagementIsAPIKeyGate(t *testing.T) { + srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats()} + h := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, okResponse{OK: true}) + }) + do := func(srv *Server, key string) int { + req := httptest.NewRequest("GET", "/x", nil) + if key != "" { + req.Header.Set("X-API-Key", key) + } + w := httptest.NewRecorder() + srv.requireAdmin(h).ServeHTTP(w, req) + return w.Code + } + if c := do(srv, ""); c != 401 { + t.Fatalf("no key = %d", c) + } + if c := do(srv, testAPIKey); c != 200 { + t.Fatalf("key = %d", c) + } + nokey := &Server{cfg: &Config{}, perfStats: NewPerfStats()} + if c := do(nokey, testAPIKey); c != 403 { + t.Fatalf("no apiKey configured = %d, want 403", c) + } +} + +func TestPerfResetAcceptsAdminSession(t *testing.T) { + srv, router := setupTestServerWithAPIKey(t, testAPIKey) + f := newAuthFixture(t, "boss@example.org") + boss := f.registerAndActivate(t, "boss@example.org", "Boss", pw) + srv.auth = f.srv.auth // requireAdmin reads s.auth per request + req := httptest.NewRequest("POST", "/api/perf/reset", nil) + req.Header.Set("Origin", testBase) + req.AddCookie(boss.cookie) + req.Header.Set(csrfHeader, boss.csrf) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + expectStatus(t, w, http.StatusOK) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 9b640c61..a286136f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -127,6 +127,10 @@ type Server struct { // Known-channels catalogue cache (issue #1323). Nil until configured; // when nil the /api/known-channels endpoint returns an empty snapshot. knownChannels *knownChannelsCache + + // Optional user management (docs/specs/2026-10-06-user-management-design.md). + // Nil unless userManagement.enabled; see initUserManagement. + auth *authService } // PerfStats tracks request performance. @@ -242,6 +246,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // for /api/* — see docs/deployment-behind-cdn.md. r.Use(cdnDetectionMiddleware) + // Optional user management: routes exist only when the feature is on. + if s.auth != nil { + s.registerAuthRoutes(r) + } + // Config endpoints r.HandleFunc("/api/config/cache", s.handleConfigCache).Methods("GET") r.HandleFunc("/api/config/client", s.handleConfigClient).Methods("GET") @@ -251,7 +260,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET") r.HandleFunc("/api/config/areas", s.handleConfigAreas).Methods("GET") r.HandleFunc("/api/config/areas/polygons", s.handleConfigAreasPolygons).Methods("GET") - r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT") + r.Handle("/api/config/geo-filter", s.requireAdmin(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT") // Readiness endpoint (gated on background init completion) r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET") @@ -266,7 +275,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET") r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET") r.HandleFunc("/api/mqtt/status", s.handleMqttStatus).Methods("GET") - r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST") + r.Handle("/api/perf/reset", s.requireAdmin(http.HandlerFunc(s.handlePerfReset))).Methods("POST") // /api/admin/prune removed in #1283 — pruning is owned by the // ingestor process (scheduled tickers + startup pass). Operators // who want an ad-hoc prune can restart the ingestor. @@ -274,11 +283,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // /api/admin/prune-geo-filter (#669 M4 / PR #738): server enqueues a // marker file; the ingestor (which holds the writable DB handle) // runs the DELETE. /status reports completion. - r.Handle("/api/admin/prune-geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST") - r.Handle("/api/admin/prune-geo-filter/status", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilterStatus))).Methods("GET") - r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET") - r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET") - r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET") + r.Handle("/api/admin/prune-geo-filter", s.requireAdmin(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST") + r.Handle("/api/admin/prune-geo-filter/status", s.requireAdmin(http.HandlerFunc(s.handlePruneGeoFilterStatus))).Methods("GET") + r.Handle("/api/debug/affinity", s.requireAdmin(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET") + r.Handle("/api/dropped-packets", s.requireAdmin(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET") + r.Handle("/api/backup", s.requireAdmin(http.HandlerFunc(s.handleBackup))).Methods("GET") // Packet endpoints r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST") @@ -453,6 +462,26 @@ func (s *Server) requireAPIKey(next http.Handler) http.Handler { }) } +// requireAdmin gates operator endpoints. It accepts a strong X-API-Key +// (exactly requireAPIKey's rules) or, when user management is on, the +// session of an admin; a cookie-authenticated unsafe method must also pass +// the CSRF check. A request that sends X-API-Key is judged on the key alone. +// With user management off this is requireAPIKey. +func (s *Server) requireAdmin(next http.Handler) http.Handler { + keyGate := s.requireAPIKey(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.auth != nil && r.Header.Get("X-API-Key") == "" { + if u, sess := s.auth.currentUser(w, r); u != nil { + if s.auth.adminSessionOK(w, r, u, sess) { + next.ServeHTTP(w, r) + } + return + } + } + keyGate.ServeHTTP(w, r) + }) +} + // --- Config Handlers --- func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) { @@ -496,9 +525,17 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { ClientRxCoverage: s.cfg.ClientRxCoverageEnabled(), ClientRfSamples: s.cfg.ClientRfSamplesEnabled(), PathTrust: &pathTrust, + UserManagement: s.clientUserManagement(), }) } +func (s *Server) clientUserManagement() *ClientUserManagement { + if s.auth == nil { + return nil + } + return &ClientUserManagement{Enabled: true} +} + func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) { type areaListEntry struct { Key string `json:"key"` diff --git a/cmd/server/types.go b/cmd/server/types.go index f8efd466..318e3d9b 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1084,6 +1084,14 @@ type ClientConfigResponse struct { ClientRxCoverage bool `json:"clientRxCoverage"` ClientRfSamples bool `json:"clientRfSamples"` PathTrust *PathTrustConfig `json:"pathTrust,omitempty"` + // Present only when user management is on, so the payload is unchanged + // for every instance that leaves it off. + UserManagement *ClientUserManagement `json:"userManagement,omitempty"` +} + +// ClientUserManagement tells the frontend that accounts exist. +type ClientUserManagement struct { + Enabled bool `json:"enabled"` } // CustomizerClientConfig is the operator-side customizer-modal knobs that diff --git a/cmd/server/user_mgmt_config.go b/cmd/server/user_mgmt_config.go new file mode 100644 index 00000000..13b93f66 --- /dev/null +++ b/cmd/server/user_mgmt_config.go @@ -0,0 +1,137 @@ +package main + +import ( + "errors" + "fmt" + "net" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/meshcore-analyzer/users" +) + +// UserManagementConfig is the "userManagement" block of config.json. +type UserManagementConfig struct { + Enabled bool `json:"enabled"` + DBPath string `json:"dbPath,omitempty"` + AdminEmails []string `json:"adminEmails,omitempty"` + PublicBaseURL string `json:"publicBaseUrl,omitempty"` + SessionDays int `json:"sessionDays,omitempty"` + TrustedProxies []string `json:"trustedProxies,omitempty"` + Mail UserMailConfig `json:"mail"` +} + +// UserMailConfig is userManagement.mail. +type UserMailConfig struct { + Provider string `json:"provider,omitempty"` + BrevoAPIKey string `json:"brevoApiKey,omitempty"` + FromEmail string `json:"fromEmail,omitempty"` + FromName string `json:"fromName,omitempty"` + WebhookSecret string `json:"webhookSecret,omitempty"` +} + +// UserManagementEnabled reports whether optional accounts are on. Nil config +// or absent section means off (the default). +func (c *Config) UserManagementEnabled() bool { + return c != nil && c.UserManagement != nil && c.UserManagement.Enabled +} + +// userMgmtSettings is the validated, resolved form the auth service runs on. +type userMgmtSettings struct { + dbPath string + adminEmails map[string]bool + baseURL *url.URL // no trailing slash, no query or fragment + secureCookie bool + sessionTTL time.Duration + trustedProxies []*net.IPNet + provider string // "brevo" or (e2etest builds only) "fake" + brevoAPIKey string + fromEmail string + fromName string + webhookSecret string +} + +const defaultSessionDays = 30 + +// fakeMailerAllowed is flipped only by the e2etest build (auth_e2e.go). +var fakeMailerAllowed bool + +// resolveUserManagement validates the block and fills defaults. It refuses +// configurations where nobody could activate an account, so the server +// fails at startup instead of running half-working. +func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, getenv func(string) string) (*userMgmtSettings, error) { + set := &userMgmtSettings{adminEmails: map[string]bool{}} + + set.dbPath = strings.TrimSpace(u.DBPath) + if set.dbPath == "" { + set.dbPath = filepath.Join(filepath.Dir(measurementDBPath), "users.db") + } + for _, raw := range u.AdminEmails { + e, err := users.NormalizeEmail(raw) + if err != nil { + return nil, fmt.Errorf("userManagement.adminEmails: %q is not a valid address", raw) + } + set.adminEmails[e] = true + } + + base, err := url.Parse(strings.TrimSpace(u.PublicBaseURL)) + if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { + return nil, errors.New("userManagement.publicBaseUrl must be an absolute http(s) URL, e.g. https://corescope.example.org") + } + base.Path = strings.TrimRight(base.Path, "/") + base.RawQuery, base.Fragment = "", "" + set.baseURL = base + set.secureCookie = base.Scheme == "https" + + days := u.SessionDays + if days <= 0 { + days = defaultSessionDays + } + if days > 365 { + days = 365 + } + set.sessionTTL = time.Duration(days) * 24 * time.Hour + set.trustedProxies = parseCIDRList(u.TrustedProxies, "userManagement.trustedProxies") + + set.provider = strings.ToLower(strings.TrimSpace(u.Mail.Provider)) + if set.provider == "" { + set.provider = "brevo" + } + set.brevoAPIKey = envOrValue(getenv, "CORESCOPE_BREVO_API_KEY", u.Mail.BrevoAPIKey) + set.webhookSecret = envOrValue(getenv, "CORESCOPE_BREVO_WEBHOOK_SECRET", u.Mail.WebhookSecret) + from, err := users.NormalizeEmail(u.Mail.FromEmail) + if err != nil { + return nil, errors.New("userManagement.mail.fromEmail must be a valid address") + } + set.fromEmail = from + set.fromName = strings.TrimSpace(u.Mail.FromName) + if set.fromName == "" { + set.fromName = "CoreScope" + } + + switch set.provider { + case "brevo": + if set.brevoAPIKey == "" { + return nil, errors.New("userManagement.mail: a Brevo API key is required (mail.brevoApiKey or CORESCOPE_BREVO_API_KEY)") + } + case "fake": + if !fakeMailerAllowed { + return nil, errors.New(`userManagement.mail.provider "fake" is only available in e2etest builds`) + } + default: + return nil, fmt.Errorf("userManagement.mail.provider %q is not supported (use \"brevo\")", u.Mail.Provider) + } + if set.webhookSecret != "" && len(set.webhookSecret) < 16 { + return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters") + } + return set, nil +} + +func envOrValue(getenv func(string) string, key, fallback string) string { + if v := strings.TrimSpace(getenv(key)); v != "" { + return v + } + return strings.TrimSpace(fallback) +} diff --git a/cmd/server/user_mgmt_config_test.go b/cmd/server/user_mgmt_config_test.go new file mode 100644 index 00000000..150a742f --- /dev/null +++ b/cmd/server/user_mgmt_config_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func validUM() *UserManagementConfig { + return &UserManagementConfig{ + Enabled: true, + AdminEmails: []string{" Boss@Example.org "}, + PublicBaseURL: "https://scope.example.org/", + Mail: UserMailConfig{BrevoAPIKey: "xkeysib-abc", FromEmail: "noreply@example.org"}, + } +} + +func noEnv(string) string { return "" } + +func TestResolveUserManagementDefaults(t *testing.T) { + set, err := resolveUserManagement(validUM(), filepath.Join("data", "meshcore.db"), noEnv) + if err != nil { + t.Fatal(err) + } + if set.dbPath != filepath.Join("data", "users.db") { + t.Errorf("dbPath = %q", set.dbPath) + } + if !set.adminEmails["boss@example.org"] { + t.Errorf("adminEmails not normalized: %v", set.adminEmails) + } + if set.baseURL.String() != "https://scope.example.org" || !set.secureCookie { + t.Errorf("baseURL = %q secure=%v", set.baseURL, set.secureCookie) + } + if set.sessionTTL != 30*24*time.Hour || set.provider != "brevo" || set.fromName != "CoreScope" { + t.Errorf("defaults: ttl=%v provider=%q fromName=%q", set.sessionTTL, set.provider, set.fromName) + } +} + +func TestResolveUserManagementEnvWins(t *testing.T) { + env := map[string]string{"CORESCOPE_BREVO_API_KEY": "from-env", "CORESCOPE_BREVO_WEBHOOK_SECRET": "env-secret-0123456789"} + set, err := resolveUserManagement(validUM(), "meshcore.db", func(k string) string { return env[k] }) + if err != nil { + t.Fatal(err) + } + if set.brevoAPIKey != "from-env" || set.webhookSecret != "env-secret-0123456789" { + t.Fatalf("env not applied: %q %q", set.brevoAPIKey, set.webhookSecret) + } +} + +func TestResolveUserManagementErrors(t *testing.T) { + cases := map[string]func(u *UserManagementConfig){ + "publicBaseUrl": func(u *UserManagementConfig) { u.PublicBaseURL = "scope.example.org" }, + "fromEmail": func(u *UserManagementConfig) { u.Mail.FromEmail = "" }, + "Brevo API key": func(u *UserManagementConfig) { u.Mail.BrevoAPIKey = "" }, + "adminEmails": func(u *UserManagementConfig) { u.AdminEmails = []string{"not-an-address"} }, + "not supported": func(u *UserManagementConfig) { u.Mail.Provider = "smtp" }, + "e2etest builds": func(u *UserManagementConfig) { u.Mail.Provider = "fake" }, + "webhookSecret": func(u *UserManagementConfig) { u.Mail.WebhookSecret = "short" }, + } + for want, mutate := range cases { + if want == "e2etest builds" && fakeMailerAllowed { + continue // the e2etest build accepts the fake provider by design + } + u := validUM() + mutate(u) + _, err := resolveUserManagement(u, "meshcore.db", noEnv) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v", want, err) + } + } +} + +func TestUserManagementEnabled(t *testing.T) { + var nilCfg *Config + if nilCfg.UserManagementEnabled() || (&Config{}).UserManagementEnabled() || + (&Config{UserManagement: &UserManagementConfig{}}).UserManagementEnabled() { + t.Fatal("off states reported as enabled") + } + if !(&Config{UserManagement: &UserManagementConfig{Enabled: true}}).UserManagementEnabled() { + t.Fatal("enabled not reported") + } +} diff --git a/cmd/server/user_mgmt_off_test.go b/cmd/server/user_mgmt_off_test.go new file mode 100644 index 00000000..29f1eff1 --- /dev/null +++ b/cmd/server/user_mgmt_off_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gorilla/mux" +) + +func TestUserManagementOffIsUnchanged(t *testing.T) { + srv, router := setupTestServer(t) + if srv.auth != nil { + t.Fatal("auth built without config") + } + for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/account/sessions"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", p, nil)) + if w.Code != 404 { + t.Errorf("%s = %d with the feature off; want 404", p, w.Code) + } + } + base := httptest.NewRecorder() + router.ServeHTTP(base, httptest.NewRequest("GET", "/api/config/client", nil)) + if strings.Contains(base.Body.String(), "userManagement") { + t.Fatal("client config mentions userManagement while off") + } + // An explicit {"enabled": false} block is byte-identical to no block. + dir := t.TempDir() + srv.cfg.UserManagement = &UserManagementConfig{Enabled: false} + if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil || srv.auth != nil { + t.Fatalf("initUserManagement off: auth=%v err=%v", srv.auth, err) + } + off := httptest.NewRecorder() + router.ServeHTTP(off, httptest.NewRequest("GET", "/api/config/client", nil)) + if off.Body.String() != base.Body.String() { + t.Fatal("client config differs between absent and disabled block") + } + if _, err := os.Stat(filepath.Join(dir, "users.db")); !os.IsNotExist(err) { + t.Fatalf("users.db created next to the measurement DB with the feature off: %v", err) + } +} + +func TestClientConfigAdvertisesUserManagement(t *testing.T) { + srv, router := setupTestServer(t) + a, _ := newTestAuthService(t) + srv.auth = a + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/config/client", nil)) + if !strings.Contains(w.Body.String(), `"userManagement":{"enabled":true}`) { + t.Fatalf("client config = %s", w.Body.String()) + } +} + +// Through the real router (RegisterRoutes, feature on): auth responses must +// never be cached by a CDN or browser. +func TestAuthRoutesThroughRealRouterAreNoStore(t *testing.T) { + srv, _ := setupTestServer(t) + a, _ := newTestAuthService(t) + srv.auth = a + router := mux.NewRouter() + srv.RegisterRoutes(router) + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/auth/me", nil)) + if w.Code == 404 { + t.Fatalf("/api/auth/me = 404 with the feature on") + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q; want no-store", got) + } +} + +func TestInitUserManagementRefusesMeasurementDB(t *testing.T) { + dir := t.TempDir() + measurement := filepath.Join(dir, "meshcore.db") + os.WriteFile(measurement, nil, 0o644) + srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{ + Enabled: true, DBPath: measurement, PublicBaseURL: testBase, + Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"}, + }}} + err := srv.initUserManagement(measurement) + if err == nil || !strings.Contains(err.Error(), "measurement database") { + t.Fatalf("err = %v", err) + } +} + +func TestInitUserManagementCreatesUsersDB(t *testing.T) { + dir := t.TempDir() + srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{ + Enabled: true, PublicBaseURL: testBase, + Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"}, + }}} + if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil { + t.Fatal(err) + } + defer srv.closeUserManagement() + if _, err := os.Stat(filepath.Join(dir, "users.db")); err != nil { + t.Fatalf("users.db not created next to the measurement DB: %v", err) + } +} diff --git a/config.example.json b/config.example.json index b8a68410..f8823610 100644 --- a/config.example.json +++ b/config.example.json @@ -429,6 +429,22 @@ } }, "_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.", + "userManagement": { + "enabled": false, + "dbPath": "", + "adminEmails": ["operator@example.org"], + "publicBaseUrl": "https://corescope.example.org", + "sessionDays": 30, + "trustedProxies": [], + "mail": { + "provider": "brevo", + "brevoApiKey": "", + "fromEmail": "noreply@example.org", + "fromName": "CoreScope", + "webhookSecret": "" + } + }, + "_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at /api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.", "customizer": { "disabledTabs": [], "_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)." diff --git a/docs/api-spec.md b/docs/api-spec.md index 118ec625..0bc7481c 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -11,6 +11,7 @@ ## Table of Contents - [Conventions](#conventions) +- [User management (optional)](#user-management-optional) - [GET /api/stats](#get-apistats) - [GET /api/health](#get-apihealth) - [GET /api/perf](#get-apiperf) @@ -100,6 +101,57 @@ They return `total` (the unfiltered/filtered count before pagination). --- +## User management (optional) + +These routes are registered only when `userManagement.enabled` is true. When it is off they do not exist, and requests to them fall through to the SPA page (HTTP 200, HTML). The webhook route exists only when `mail.webhookSecret` is also set. Sessions use the `cs_session` cookie (HttpOnly, SameSite=Lax). Every state-changing request that authenticates with the cookie needs an `Origin` (or `Referer`) equal to `publicBaseUrl` and the header `X-CS-CSRF: `. Errors are `{"error": ""}`. Rate-limited calls answer `429` with `Retry-After`. Request bodies are JSON. On every route except the webhook they are limited to 16 KiB and unknown fields are rejected with `400`; the webhook takes up to 256 KiB of Brevo's payload as is. + +| Method and path | Auth | Body and response | +|---|---|---| +| `POST /api/auth/register` | origin | `{email, displayName, password}` -> `{ok, message}`, identical for known addresses. Registering a pending address again replaces its password and name and mails a new link | +| `POST /api/auth/activate` | origin | `{token, password}` -> me + session cookie. `410` expired, used or already activated; `401` "wrong password for this account" (the link stays usable); `409` "account changed, try again" when the account changed between the password check and the activation (for example a re-register) | +| `POST /api/auth/login` | origin | `{email, password}` -> me + cookie. `401` "incorrect email or password" for every failure, including pending and disabled accounts | +| `POST /api/auth/logout` | origin | -> `{ok}` | +| `GET /api/auth/me` | session | -> `{id, email, displayName, role, csrfToken}`. `401` when not logged in | +| `POST /api/auth/forgot` | origin | `{email}` -> `{ok, message}` | +| `POST /api/auth/reset` | origin | `{token, password}` -> `{ok, message}`. Ends all sessions and any pending email-change link. `410` expired or used | +| `PATCH /api/account` | session | `{displayName}` -> me | +| `POST /api/account/password` | session | `{currentPassword, newPassword}` -> `{ok, message}`. Ends the other sessions and outstanding reset and email-change links | +| `POST /api/account/email` | session | `{newEmail, currentPassword}` -> `{ok, message}`. Rate-limited. A confirmation goes to the new address and a notice to the old one; the answer is the same when the new address is already in use | +| `POST /api/account/confirm-email` | origin | `{token}` -> `{ok, message}`. `410` expired or used; `409` address now in use | +| `GET /api/account/sessions` | session | -> `[{id, createdAt, lastSeenAt, expiresAt, userAgent, current}]` | +| `DELETE /api/account/sessions/{id}` | session | -> `{ok}`. `404` not your session | +| `DELETE /api/account` | session | `{currentPassword}` -> `{ok, message}`. `409` you are the last admin | +| `GET /api/admin/users?status=&role=&q=` | admin | -> `[adminUser]`. `status` is `pending`, `active` or `disabled`; `role` is `user` or `admin` | +| `GET /api/admin/users/{id}` | admin | -> `{user, sessions, mail, audit}` | +| `POST /api/admin/users/{id}/disable` | admin | -> adminUser. Active accounts only; ends the user's sessions and links | +| `POST /api/admin/users/{id}/enable` | admin | -> adminUser. Disabled accounts only | +| `DELETE /api/admin/users/{id}` | admin | -> `{ok}` | +| `POST /api/admin/users/{id}/role` | admin | `{role}` (`user` or `admin`) -> adminUser | +| `POST /api/admin/users/{id}/resend-activation` | admin | -> adminUser. Pending accounts only | +| `POST /api/admin/users/{id}/activate` | admin | -> adminUser. Pending accounts only; the address stays unverified | +| `POST /api/admin/users/{id}/mail/{mailId}/refresh` | admin | -> mail record with `events`, pulled from the provider. `502` provider unavailable | +| `POST /api/mail/brevo/webhook` | `Authorization: Bearer ` | Brevo transactional event payload -> `{ok}`. `401` on a wrong secret | + +`adminUser` = `{id, email, displayName, role, status, createdAt, activatedAt, activatedManually, activatedBy, lastLoginAt, emailBouncing, configAdmin, lastMail}`. `status` is `pending`, `active` or `disabled`. + +Error codes you can get on the routes above: + +| Code | Meaning | +|---|---| +| `400` | Validation failed (email, display name, password, role, filter, id) | +| `401` | Not logged in, or wrong password on activate or login | +| `403` | `"CSRF check failed"`, `"request origin not allowed"`, `"admin role required"`, or `"current password is incorrect"` (password change, email change, account delete) | +| `404` | `"user not found"` (admin `{id}` routes), `"mail not found"` (mail refresh), `"session not found"` (`DELETE /api/account/sessions/{id}`) | +| `409` | A state guard: `"only active accounts can be disabled; ..."`, role change on a pending account, disable or delete of yourself, disable, delete or demotion of a config admin or the last admin, enable of a non-disabled account, `"this mail has no provider message id"` (mail refresh), `"account changed, try again"` (activate) | +| `410` | Link expired, invalid or already used | +| `429` | Rate limit; see `Retry-After` | +| `502` | `"mail provider unavailable"` (mail refresh) | +| `503` | `"mail could not be sent, try again later"` | + +With user management on, every endpoint that needs `X-API-Key` also accepts an admin session (plus the CSRF header for unsafe methods). A request that sends `X-API-Key` is judged on the key alone. `GET /api/config/client` gains `"userManagement": {"enabled": true}` only when the feature is on. + +--- + ## GET /api/stats Server-wide statistics. Lightweight, cached 10s. diff --git a/docs/specs/2026-10-06-user-management-design.md b/docs/specs/2026-10-06-user-management-design.md new file mode 100644 index 00000000..0a18a87e --- /dev/null +++ b/docs/specs/2026-10-06-user-management-design.md @@ -0,0 +1,487 @@ +# Optional User Management (Sub-project A: Foundation), Design Spec + +**Date:** 2026-10-06 +**Status:** Approved (design), implemented on this branch. +**Scope:** sub-project **A** of a five-part track (see *Roadmap*). B–E get their own +spec → plan → implementation cycle and build on A. + +--- + +## Problem + +CoreScope has exactly one notion of identity: a single shared `apiKey`, sent as +`X-API-Key`, that gates a handful of operator endpoints (`requireAPIKey`, +`cmd/server/routes.go:420`). Everything a visitor configures lives in their own +browser's `localStorage` (≈80 keys: own nodes, favorites, customizer delta, filters, +UI state). That model is deliberate and stays the default, but it repeatedly blocks +features that need a *person*: + +- **Own nodes and favorites across devices.** #895 ("must manually claim and favorite + all the nodes again"), #1765/#1767 (My Repeaters shipped "frontend-only" because + there is no server-side starred set), #106, #381, #665, #2100. +- **Per-user preferences vs operator defaults.** The customizer has three layers + (#502, #288). #1508 hides admin tabs, but that is cosmetic: "everything is + browser-local localStorage state … doesn't actually prevent state mutation." +- **Alerts to a person.** #775 (alerting epic, closed unstaffed), #663 (battery + thresholds with no delivery), #664/#666, #730. +- **Operator workflows in the UI.** #331 was closed because "auth model, audit log, + write-validation all need design BEFORE" a server-write UI. #819 deferred + server-side geofilter save for lack of auth. #2092 (suggested hashtag channels with + admin review) was declined upstream for now, pending a general user-management + design ("so that it is generally usable across CoreScope rather than built for one + feature"). + +There is no session, cookie, CSRF, rate-limit or mail code anywhere (`public/app.js:1769`: +"Login — removed, no auth yet"). + +## Goals + +1. An **optional** user system, **off by default**. With it off, CoreScope behaves + and responds exactly as today. That includes routes, API payloads, UI and files + on disk. +2. Self-registration with **email + password**, activated by a link sent to that + address; activated accounts get role `user`. +3. Roles `user` and `admin`. Admins can manually activate, disable, enable, delete, + promote and demote users, and see the delivery status of the mails sent to them. +4. A foundation that B–E build on without redesign: sessions, roles, an audit log, + a mailer. + +## Non-goals (this spec) + +- Syncing `localStorage` to the profile (sub-project B). +- Admin dashboard or extra statistics (C), approval flows (D), alerts (E). +- Requiring login to *view* the dashboard (#1835). The dashboard stays public for + anonymous visitors; logging in only adds features. Out of scope for this track. +- Channel keys (PSKs). They never go to the server: "Server Must NEVER Handle User + Channel Keys" (#725). Changing that needs a separate, explicit design decision. +- OAuth/SSO, 2FA, magic-link login. Possible later; the schema doesn't preclude them. + +## Decisions + +| # | Question | Decision | +|---|---|---| +| 1 | Login method | Email + password, activation by mailed link, password reset by mailed link. | +| 2 | First admin | `adminEmails` in config: an account activated with a listed address becomes `admin`. Config admins can't be demoted, disabled or deleted from the UI. | +| 3 | Existing `apiKey` | Keeps working. Every endpoint gated today accepts `X-API-Key` **or** an admin session. | +| 4 | Dashboard visibility | Always public. Login is additive. | +| 5 | Storage | A separate SQLite file `users.db`, opened read-write by the server only, only when the feature is on. The measurement DB stays read-only to the server. | +| 6 | Profile | Email (private) plus a display name (shown to others in later sub-projects). | +| 7 | Mail | Brevo transactional API by default, behind a `Mailer` interface. Delivery events are recorded and shown to admins. | + +### Why a separate `users.db` (decision 5) + +The server opens the measurement DB with `mode=ro` (`cmd/server/db.go:107`), and +`cmd/server/readonly_invariant_test.go` enforces it (#1283). Three options were +weighed: + +- **Ingestor-owned tables fed through a file queue** (like `internal/prunequeue`, or + #2092's design). This keeps the invariant literally, but login, activation and + password changes become asynchronous, and logging in would depend on the ingestor + running. Rejected. +- **A JSON file** (like `SaveGeoFilter` rewriting `config.json`). No concurrency + control or indexes; it breaks down once B and E add per-user rows. Rejected. +- **A separate SQLite file owned by the server.** Auth is synchronous, users are + isolated from measurement data, and backing up or resetting one doesn't touch the + other. Chosen. + +The invariant is restated rather than dropped: **the server never writes +measurement data**. `users.db`, written only through `internal/users`, is the single +exception, and the invariant test is extended to pin exactly that. + +## Configuration + +A new optional block. If the block is absent or `enabled` is false, the feature is +off. + +```json +"userManagement": { + "enabled": false, + "dbPath": "data/users.db", + "adminEmails": ["operator@example.org"], + "publicBaseUrl": "https://corescope.example.org", + "sessionDays": 30, + "trustedProxies": [], + "mail": { + "provider": "brevo", + "brevoApiKey": "", + "fromEmail": "noreply@example.org", + "fromName": "CoreScope", + "webhookSecret": "" + } +} +``` + +- `dbPath` defaults to `users.db` in the same directory as the analyzer database when + empty. + +- The shape follows the existing opt-in pattern: a pointer sub-struct with + `Enabled bool`, plus a nil-safe accessor where nil means off (`ClientRxCoverage`, + `cmd/server/config.go:170,268-277`). +- **Off:** no routes are registered (requests fall through to the SPA page, 200 HTML, + like any unknown path, rather than a router 404), `users.db` is + not opened or created, and `/api/config/client` does not contain the + `userManagement` field at all, so its payload is byte-identical to today. +- **On:** startup fails with a clear error unless all of the following hold: + - `publicBaseUrl` is an absolute `http(s)` URL. + - `mail.fromEmail` is set. + - A Brevo API key is available. + - `dbPath` is writable. + + A half-working setup where nobody can activate is worse than refusing to start. +- `publicBaseUrl` builds every link in every mail. The request `Host` header is never + used, so a forged host can't poison a reset link. +- Secrets can come from the environment instead of `config.json`: + `CORESCOPE_BREVO_API_KEY` and `CORESCOPE_BREVO_WEBHOOK_SECRET`. The environment + value wins. +- `trustedProxies`: CIDRs whose `X-Forwarded-For` is trusted for rate limiting. When + it is empty, the TCP peer address is used, with the same rule as the `/ws` limiter + (`clientIP` in `cmd/server/ws_limits.go`): a loopback or private peer means clients + cannot be told apart, so per-IP limits are off (see *Security*). +- `adminEmails` are compared case-insensitively after trimming. +- `config.example.json` gets the block with a `_comment` in the file's existing + style. + +## Components + +Each unit has one job and can be tested on its own. + +| Unit | Responsibility | Depends on | +|---|---|---| +| `internal/users` | `users.db`: open, migrate, CRUD for users, sessions, tokens, audit log, mail log. Password hashing. No HTTP. | `modernc.org/sqlite`, `golang.org/x/crypto/argon2` | +| `internal/mailer` | `Mailer` interface `Send(ctx, Message) (messageID string, err error)`. `Events(ctx, messageID) ([]Event, error)` (pull). A Brevo implementation and an in-memory fake for tests. | `net/http` | +| `cmd/server/auth_*.go` | HTTP handlers, session middleware, role checks, CSRF, rate limiting, mail templates, the Brevo webhook. | `internal/users`, `internal/mailer` | +| `public/account.js` | Login, register, activate, forgot, reset and "my account" views. The header account control. | existing helpers | +| `public/admin-users.js` | The admin user table and actions. | existing helpers | + +New dependency: `golang.org/x/crypto`, a Go-team module. No new frontend +dependencies and no build step (AGENTS.md). + +## Data model (`users.db`) + +The schema has its own `schema_version` and forward-only migrations, independent of +`internal/dbschema`. It is opened in WAL mode with `busy_timeout`. + +- **`users`**: + - `id` INTEGER PK + - `email` TEXT UNIQUE (stored lowercase, trimmed) + - `display_name` TEXT (2–32 chars after trim, not unique, no control or bidi + characters) + - `password_hash` TEXT (argon2id, PHC string with parameters, so they can be + raised later) + - `role` (`user`|`admin`), `status` (`pending`|`active`|`disabled`) + - `created_at`, `activated_at`, `last_login_at` + - `activated_by` (nullable admin user id; null means activated by link) + - `email_bouncing` BOOLEAN, set on a hard bounce +- **`sessions`**: + - `token_hash` PK (SHA-256 of a 256-bit random token; the raw token exists only in + the cookie) + - `user_id`, `csrf_token` + - `created_at`, `expires_at`, `last_seen_at` + - `user_agent` (truncated) +- **`tokens`**: one-time links. + - `token_hash` PK, `user_id` + - `purpose` (`activate`|`reset`|`email_change`), `new_email` (for `email_change`) + - `expires_at`, `used_at` + - Lifetimes: activate 48 h, reset 1 h, email change 24 h. +- **`audit_log`**: + - `id`, `at`, `actor_user_id` (nullable; null means the system or the API key) + - `action`, `target_user_id`, `detail` (JSON) + - Rows outlive deleted users; they then show as "deleted user #N". +- **`mail_log`**: + - `id`, `user_id` (nullable after delete), `to_email` + - `purpose`, `provider_message_id`, `sent_at` + - `last_event`, `last_event_at`, `last_reason` + - It is pruned after 90 days. +- **`mail_events`**: + - `mail_id`, `event`, `at`, `reason` + - The full history behind `mail_log.last_event`, pruned with it. + +## Flows + +All request and response bodies are JSON. Error responses use the server's existing +error shape. + +### Register and activate + +1. `POST /api/auth/register {email, displayName, password}`. The server validates: + - Email syntax, at most 254 chars. + - Password: 10–128 chars, no composition rules (NIST 800-63B). + - Display name: per the rule above. +2. A user is created with status `pending` and an activation mail is sent with + `{publicBaseUrl}/#/account/activate?token=…`. +3. If the address is already registered, the response is **identical** to the + success case ("check your mail"). This prevents account enumeration. What gets + mailed depends on the account: + - An active or disabled account gets a mail: "someone tried to register with your + address; if it was you, log in or reset your password." + - A still-pending account stores the newest password and display name and gets a + fresh activation link instead (newest registration wins). A squatter therefore + cannot activate the owner into the squatter's password. +4. `POST /api/auth/activate {token, password}`: + - The token is checked without being consumed. The password must match the pending + account's password. A wrong password answers 401, the link stays usable, and + attempts are rate-limited per IP and per account. + - Then, in one transaction, it consumes the token, sets the status to `active` and + sets the role to `admin` if the address is in `adminEmails`, and starts a session. + The transaction applies only while the account is still pending with the password + hash that was checked; if a re-register (or an admin) changed it in between, it + answers 409 "account changed, try again" and the link is not consumed. +5. Pending accounts with an expired token are pruned periodically. +6. The admin action "resend activation" issues a fresh token and invalidates the old + one. + +### Log in and log out + +- `POST /api/auth/login {email, password}`. It succeeds only for `active` users. + - Any failure (unknown address, wrong password, pending, disabled) returns the same + 401 message after a constant-cost hash comparison. For an unknown address the + server compares against a dummy hash. + - Success sets the cookie `cs_session`: `HttpOnly`, `SameSite=Lax`, `Path=/`. + `Secure` is set when `publicBaseUrl` is https. + - The session expires after `sessionDays`. It is extended (sliding) when + `last_seen_at` is older than one day. +- `POST /api/auth/logout` (checked by `Origin` only, see *Security*) deletes the session row and clears the cookie. +- `GET /api/auth/me` returns `{id, email, displayName, role, csrfToken}`, or 401. + +### Forgot and reset the password + +- `POST /api/auth/forgot {email}`. The response is always the same. A mail is sent + only if an `active` account exists. +- `POST /api/auth/reset {token, password}` sets the password, consumes the token and + **ends all sessions** of that user. It also ends outstanding email-change links. + +### My account + +- `PATCH /api/account {displayName?}`. +- `POST /api/account/password {currentPassword, newPassword}`. It ends all other + sessions and ends outstanding email-change and reset links. +- `POST /api/account/email {newEmail, currentPassword}`. A confirmation link goes to + the new address, and the change applies only after the click. The request is + rate-limited per user and per new address. The old address is always told that a + change was requested, also when the new address is already taken (no enumeration). +- `GET /api/account/sessions` lists the sessions as device and last seen. + `DELETE /api/account/sessions/{id}` revokes one. +- `DELETE /api/account {currentPassword}` deletes the account, its sessions and + tokens. Mail-log rows keep only the hashed address. Config admins can delete their + account; activating again with the same address makes them admin again. The last + admin can't delete their own account (409); they promote someone else first. + +### Admin user management + +All of these require role `admin` and live under `/api/admin/users`. + +- `GET /api/admin/users?status=&role=&q=` returns users with their last mail status + (`last_event`, `last_reason`, `email_bouncing`). +- `GET /api/admin/users/{id}` returns the user, their sessions (count, last seen), + their mail log with events, and the audit entries that concern them. +- `POST …/{id}/disable` works only for active accounts (for a pending one, delete or + activate instead). It sets the status to `disabled`, ends all their sessions + immediately and ends all their outstanding links. `POST …/{id}/enable` reverses it. +- `DELETE …/{id}` is a hard delete, the same as self-delete. +- `POST …/{id}/role {role}`. These guards apply: + - A config admin (an account that is admin **and** listed in `adminEmails`) can't be + demoted, disabled or deleted. A pending account on a listed address can be deleted. + - A role change on a pending account answers 409. + - The last admin can't be demoted, disabled or deleted. + - An admin can't disable or delete themselves; they use "my account" for that. +- `POST …/{id}/resend-activation` works only for pending users. +- `POST …/{id}/activate` manually activates a pending user, for when mail keeps + failing (bounces, spam filters, Brevo outage). + - It sets the status to `active` and `activated_at`, applies the `adminEmails` role + rule exactly like link activation, and invalidates any outstanding activation + tokens. + - The address is then **not verified** by the user. The user row records + `activated_by` (the admin's id) and the audit log records + `user.activate.manual`, so this stays visible in the admin views. + - No session is created. The user logs in with the password they chose at + registration, or uses "forgot password" once mail works. +- `POST …/{id}/mail/{mailId}/refresh` pulls the events for that message from the + provider API and writes the audit row `user.mail.refresh`. +- Every action writes an `audit_log` row. + +### Mail delivery status (Brevo feedback) + +- On send, the provider's `messageId` is stored in `mail_log`. +- **Webhook:** `POST /api/mail/brevo/webhook`. Brevo is configured to call it for + these transactional events: `delivered`, `opened`, `click`, `soft_bounce`, + `hard_bounce`, `invalid_email`, `deferred`, `spam`, `blocked`, `error`. + - Each event is appended to `mail_events` and updates `mail_log.last_event` and + `last_reason`. + - `hard_bounce` and `invalid_email` set `users.email_bouncing`. The account is not + disabled automatically; the admin decides. + - The endpoint is authenticated with `webhookSecret`. If it is unset, the endpoint + is not registered. + - Brevo's webhook object supports `"auth": {"type": "bearer", "token": …}` + (verified 2026-10-06), so the webhook is created with bearer auth. The server + checks `Authorization: Bearer ` in constant time. The secret + must be at least 16 characters. + - Unknown message IDs are ignored with 200, so Brevo doesn't retry forever. Bodies + are size-capped. +- **Pull fallback:** for instances Brevo can't reach, the admin "refresh" action + calls Brevo's transactional events API for that `messageId`. The same ingestion + code updates `mail_events`. +- **UI:** the admin table shows a status chip per user: sent, delivered, opened, + bounced, blocked or spam, with the reason in a tooltip. The detail view shows the + timeline. "Opened" carries a note: tracking pixels are auto-loaded by some clients + (Apple Mail Privacy Protection) and blocked by others, so the value is indicative + only. +- Users never see the mail log. The webhook payload is not stored raw; only the + event, the time and the reason are kept. + +### Existing API-key endpoints + +`requireAPIKey` becomes `requireAdmin`, which accepts **either** a valid strong +`X-API-Key` (exactly today's checks, `routes.go:420-437`) **or** a session with role +`admin` that passes the CSRF check. When the feature is off, only the API-key path +exists and the behavior is unchanged. In the UI, the customizer's geofilter tab hides +its API-key field when an admin is logged in, and `perf.js` sends the CSRF token on +`POST /api/perf/reset`. That fixes `perf.js:347`, which never sent credentials, for +logged-in admins. + +## Security (cross-cutting) + +- **CSRF:** every state-changing request authenticated by cookie must carry: + - an `Origin` header (or, without one, a `Referer`) whose origin equals + `publicBaseUrl`'s origin, **and** + - a header `X-CS-CSRF` equal to the session's `csrf_token`. + + The exception is logout: it is checked by `Origin` only, without `X-CS-CSRF`, + because a forced logout is low impact. + + Requests authenticated by `X-API-Key` are exempt, because they carry no ambient + credential. CORS stays as it is: no credentialed CORS, and + `corsAllowedOrigins` doesn't widen auth. +- **Rate limits:** in-memory token buckets keyed by client IP and by normalized + email. + - Login: 10 per 15 min. + - Register, forgot and resend: 5 per hour. + - Webhook: generous and per-IP only. + + The client IP is taken from `X-Forwarded-For` only when the peer is in + `trustedProxies`. A limit returns 429 with `Retry-After`. + + With `trustedProxies` empty, the rule of the `/ws` limiter applies + (`clientIP`, `cmd/server/ws_limits.go`). A loopback or private TCP peer means IPs + cannot be told apart, so per-IP limits are off. Per-address and per-account keys + still apply, and a startup warning says so. The webhook limiter is per-IP only, so + it is off too. The buckets are capped at 100000 keys. At the cap, new keys are + refused until buckets refill. +- **Logs:** tokens, passwords, password hashes and cookies are never logged. + Addresses are logged only in audit and mail tables, not in the server log. + Mail-provider error texts are redacted (email addresses replaced) before logging. +- **Headers:** auth responses send `Cache-Control: no-store`. +- **Output:** all user-supplied strings (display name, email) are rendered through + the existing escape helpers (`test-xss-escape-sinks.js`, `test-preflight-xss-gate.js`). +- **WebSocket:** unchanged and public. Sessions add nothing to `/ws` in A. + +## Frontend + +- **Feature detection:** `/api/config/client` gains `userManagement: {enabled: true}` + only when the feature is on. If the field is absent, the frontend adds no UI and + makes no extra requests. That makes "off" pixel-identical and keeps older servers + safe. +- **Header control:** "Log in" at the right of the top nav. When logged in it shows + the display name with a menu: My account, Users (admins only), Log out. On phones + (<=768px) the top-bar control is hidden, so a conditional "Log in" / "My account" + entry is in the bottom-nav More sheet and the nav drawer. Icons are Phosphor, as elsewhere + (#1648). +- **Routes:** + - `#/account/login`, `#/account/register`, `#/account/activate`, `#/account/forgot`, + `#/account/reset`, `#/account/confirm-email` + - `#/account`: profile, password, email, sessions, delete + - `#/admin/users`: a table ordered newest first by the server (no table-sort), with + status and role filters, search, a detail panel, and `confirm()` dialogs for + destructive actions. The filters and the open detail are deep-linked + (`#/admin/users?status=&role=&q=&id=`). +- **State:** on load, if the feature is on, the frontend calls `GET /api/auth/me` + once. The result is kept in memory (`window.CS_USER`). A 401 from any authed call + clears it and shows a "you were logged out" toast. The rest of the page keeps + working anonymously. +- **Language and mails:** the UI is English, like the rest of CoreScope. Mails are + English, sent as both HTML and text. The subject prefix and the sender + name come from `userManagement.mail.fromName` (default "CoreScope"), not the + branding config. +- **Accessibility:** the new views must pass the existing axe and contrast tests and + the touch-target rules. + +## Error handling + +| Situation | Behavior | +|---|---| +| Brevo send fails at register | 503 "mail could not be sent, try again later". The user row is rolled back, so a retry works. The error is logged without the token. | +| Brevo send fails at forgot, resend or email change | 503. The token is invalidated. | +| `users.db` can't be opened or migrated | The server refuses to start, with the path and cause. Measurement data is untouched. | +| Expired session, or the user was disabled or deleted | 401. The UI clears its user state and shows a toast. | +| Expired or used token | 410 with a clear message and a "send a new link" action. | +| Webhook with bad auth | 401. Nothing is written. | +| Config admin removed from `adminEmails` | They keep the `admin` role they have, but lose the config protection. Startup logs this as a note. | + +## Testing + +- **`internal/users` (Go unit):** + - Argon2id hash round-trip and parameter parsing. + - The token lifecycle: expiry, single use, purpose mismatch. + - Status and role transitions, and the last-admin and config-admin guards. + - Migration from an empty file. Pruning. +- **`internal/mailer`:** the Brevo client against an `httptest` server: the request + shape, the API-key header, error mapping, and events parsing. The fake mailer + records messages for the HTTP tests. +- **HTTP (`cmd/server`, fake mailer):** + - The full register → mail → activate → login → me → logout flow. + - Identical enumeration responses for register and forgot. + - Reset ends sessions. Disable ends sessions. + - Manual activation: only for pending users, invalidates the activation token, + records `activated_by` and an audit row, and applies the `adminEmails` rule. + - Rate limits and 429, including `trustedProxies` handling. + - CSRF rejection: wrong origin, missing header, wrong token. + - The admin-only routes return 403 for `user` and 401 for anonymous requests. + - Gated endpoints accept the API key or an admin session. + - The webhook: auth, unknown IDs, and event ingestion setting `email_bouncing`. +- **"Off is unchanged":** + - With the block absent or `enabled: false`: no new route is registered (the unit + test's API-only router answers 404; the real server falls through to the SPA + page) and no `users.db` file is created. `/api/config/client` has no + `userManagement` key, and + it is byte-identical between "block absent" and `enabled: false`. + - All existing tests pass unchanged. +- **Read-only invariant:** `readonly_invariant_test.go` is extended. Write-capable + opens are allowed only inside `internal/users`, and only for the configured + `dbPath`. All existing prohibitions on the measurement DB stay. +- **Playwright E2E** (server started with the feature on, the fake mailer, and a + test-only endpoint that returns the last mail's link, compiled in only under a + test build tag): + - Register, activate, log in and log out. + - An admin disables a user, and that user's next action shows "logged out". + - axe on the new views. + - With the feature off: no login control. + +## Documentation + +- `config.example.json`: the block plus `_comment`. +- `docs/user-guide/accounts.md`: + - For operators: enabling the feature, a Brevo free account (sender domain + verification, API key, webhook setup), the first admin, and backing up + `users.db`. + - For users: registering, logging in, and deleting an account. +- `docs/api-spec.md` and OpenAPI (`cmd/server/openapi.go`): the new endpoints, a + `cookieAuth` security scheme next to `apiKey`. +- `AGENTS.md`: restate the invariant as "the server never writes measurement data; + `users.db` via `internal/users` is the single exception (user management, opt-in)". +- `README.md` and `AGENTS.md` say "all API endpoints are public, no auth required". + Those lines describe the public live instance (analyzer.00id.net), which does not + enable the feature, so they stay unchanged. + +## Roadmap (after A) + +Each item gets its own spec. The order can change. + +- **B. Settings sync.** Profile-stored own nodes, favorites, the customizer delta and + selected filters and UI state. Merge rules for "local vs profile" on login. Explicit + exclusion of channel keys and decrypted-message caches (#725). +- **C. Admin dashboard and statistics.** User stats, the audit-log viewer, and + admin-only views of perf, health and MQTT status. Server-enforced customizer tab + restrictions (#1508). +- **D. Approval flows.** A generic proposal/review mechanism, with #2092 (suggested + hashtag channels) as its first consumer, attributing proposals to display names. +- **E. Notifications.** Per-user subscriptions to node events (offline, battery, + foreign advert; #775, #663, #730), delivered via the mailer. diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md new file mode 100644 index 00000000..95369c99 --- /dev/null +++ b/docs/user-guide/accounts.md @@ -0,0 +1,96 @@ +# Accounts (optional user management) + +CoreScope runs without accounts by default: every page is public and settings live in +each visitor's browser. Operators can turn on **accounts**. The dashboard stays public; +logging in only adds things. + +- Visitors can register with an email address, a display name and a password, and + activate the account through a link mailed to that address. +- **Admins** manage users (activate, disable, delete, promote) and can use the operator + actions (geofilter save, prune, backup, perf reset) without the API key. + +With the feature off (the default) the account routes are not registered: requests to +`/api/auth/*`, `/api/account/*` and `/api/admin/users/*` fall through to the normal page, +and no login or account control appears in the interface. + +## For operators + +### 1. Get a Brevo account + +1. Create a free account at brevo.com. The free plan's daily volume is far more than + account mails need. +2. Verify the sender address or domain you will send from (Brevo: *Senders, Domains & + Dedicated IPs*). Mail from an unverified sender is rejected or lands in spam. +3. Create an API key (*SMTP & API, API Keys*). + +### 2. Configure + +Add to `config.json` (see `config.example.json` for every key): + +```json +"userManagement": { + "enabled": true, + "adminEmails": ["you@example.org"], + "publicBaseUrl": "https://corescope.example.org", + "mail": { "fromEmail": "noreply@example.org", "fromName": "My CoreScope" } +} +``` + +Provide the API key as `mail.brevoApiKey` or, better, the environment variable +`CORESCOPE_BREVO_API_KEY`. Restart the server. It refuses to start if the mail setup is +incomplete, and the log says what is missing. + +| Key | Meaning | +|---|---| +| `adminEmails` | Addresses that become admin on activation. They cannot be demoted, disabled or deleted from the UI. Remove an address here first. | +| `publicBaseUrl` | The address visitors use. Every mail link is built from it, never from the request. It must match the browser origin, because state-changing requests from another origin are refused. | +| `dbPath` | Where accounts are stored. Default: `users.db` next to the analyzer database. | +| `sessionDays` | Login lifetime, extended while in use. Default 30, maximum 365. | +| `trustedProxies` | CIDRs of your reverse proxy, so the per-IP login limits see real client IPs. Without it, behind a proxy every client shares the proxy's IP for the per-IP limits (they are switched off when that IP is loopback or private). Per-address and per-account limits apply either way. | +| `mail.webhookSecret` | Enables delivery status (below). At least 16 characters. | + +### 3. The first admin + +Register at `#/account/register` with an address from `adminEmails`, click the activation +link and enter the password you chose. You are now admin and can promote others in +**Users**. + +### 4. Delivery status (optional) + +To see per user whether mails were delivered, bounced, blocked or marked as spam: + +1. Set `mail.webhookSecret` (16+ random characters), or `CORESCOPE_BREVO_WEBHOOK_SECRET`. + The webhook route exists only when a secret is set. +2. In Brevo, create a **transactional** webhook to + `https:///api/mail/brevo/webhook` with these events: delivered, + opened, clicked, soft bounce, hard bounce, invalid email, deferred, spam, blocked, + error. Use **bearer** authentication with the same secret. + +If Brevo cannot reach your instance, use **Refresh status** in a user's details instead: +CoreScope then asks Brevo directly. "Opened" is indicative only. Some mail apps load +tracking pixels automatically, and others block them. + +### When mail fails + +- In **Users**, *Resend mail* sends a fresh activation link to a pending account. +- *Activate* activates a pending account by hand. The address is then unverified; the + row shows "manual" and the audit log records who did it. + +### Backups + +`users.db` holds password hashes and addresses. Back it up together with the analyzer +database, and protect it the same way. Deleting it removes all accounts and nothing else. + +## For users + +- **Register:** *Log in, Create an account*, then click the link in the mail within 48 + hours and enter your password to finish. Registering a pending (not yet activated) + address again replaces the password and sends a new link. Registering an address that + is already activated changes nothing; its owner gets a notice mail instead. +- **Forgot password:** *Log in, Forgot password?* The link works once, for one hour, and + logs out all your devices. +- **My account:** change your display name, password or address, see your logged-in + devices, or delete your account. A new address is confirmed from a link sent to it, and + your old address gets a notice. Changing your password logs out your other devices. + +Admins can see your display name and email address in the Users list. diff --git a/internal/mailer/brevo.go b/internal/mailer/brevo.go new file mode 100644 index 00000000..3cf82c4b --- /dev/null +++ b/internal/mailer/brevo.go @@ -0,0 +1,196 @@ +package mailer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const brevoBaseURL = "https://api.brevo.com/v3" + +// Brevo sends through Brevo's transactional API. +type Brevo struct { + APIKey string + FromEmail string + FromName string + BaseURL string // overridable for tests + HTTP *http.Client +} + +func NewBrevo(apiKey, fromEmail, fromName string) *Brevo { + return &Brevo{APIKey: apiKey, FromEmail: fromEmail, FromName: fromName, + BaseURL: brevoBaseURL, HTTP: newBrevoHTTPClient()} +} + +// newBrevoHTTPClient never follows redirects, so the api-key header cannot be +// forwarded to another host. +func newBrevoHTTPClient() *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type brevoAddress struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` +} + +type brevoSendRequest struct { + Sender brevoAddress `json:"sender"` + To []brevoAddress `json:"to"` + Subject string `json:"subject"` + HTMLContent string `json:"htmlContent,omitempty"` + TextContent string `json:"textContent,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type brevoError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (b *Brevo) Send(ctx context.Context, m Message) (string, error) { + if m.HTML == "" && m.Text == "" { + return "", errors.New("brevo: send: message has no content") + } + req := brevoSendRequest{ + Sender: brevoAddress{Email: b.FromEmail, Name: b.FromName}, + To: []brevoAddress{{Email: m.To, Name: m.ToName}}, + Subject: m.Subject, + HTMLContent: m.HTML, + TextContent: m.Text, + } + if m.Tag != "" { + req.Tags = []string{m.Tag} + } + body, err := json.Marshal(req) + if err != nil { + return "", err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, b.BaseURL+"/smtp/email", bytes.NewReader(body)) + if err != nil { + return "", err + } + httpReq.Header.Set("Content-Type", "application/json") + data, status, err := b.do(httpReq) + if err != nil { + return "", fmt.Errorf("brevo: send: %w", err) + } + if status != http.StatusCreated && status != http.StatusAccepted { + return "", brevoErr("send", status, data) + } + var out struct { + MessageID string `json:"messageId"` + } + if err := json.Unmarshal(data, &out); err != nil || out.MessageID == "" { + return "", errors.New("brevo: send: response has no messageId") + } + return out.MessageID, nil +} + +type brevoEventsResponse struct { + Events []struct { + Date string `json:"date"` + Email string `json:"email"` + Event string `json:"event"` + MessageID string `json:"messageId"` + Reason string `json:"reason"` + } `json:"events"` +} + +func (b *Brevo) Events(ctx context.Context, messageID string) ([]Event, error) { + q := url.Values{"messageId": {messageID}, "limit": {"100"}, "sort": {"asc"}} + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BaseURL+"/smtp/statistics/events?"+q.Encode(), nil) + if err != nil { + return nil, err + } + data, status, err := b.do(httpReq) + if err != nil { + return nil, fmt.Errorf("brevo: events: %w", err) + } + if status != http.StatusOK { + return nil, brevoErr("events", status, data) + } + var resp brevoEventsResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("brevo: events: %w", err) + } + out := make([]Event, 0, len(resp.Events)) + for _, e := range resp.Events { + at, err := parseBrevoDate(e.Date) + if err != nil { + at = time.Now().UTC() + } + out = append(out, Event{MessageID: e.MessageID, Email: e.Email, Event: NormalizeBrevoEvent(e.Event), Reason: e.Reason, At: at}) + } + return out, nil +} + +func (b *Brevo) do(r *http.Request) ([]byte, int, error) { + r.Header.Set("api-key", b.APIKey) + r.Header.Set("Accept", "application/json") + client := b.HTTP + if client == nil { + client = newBrevoHTTPClient() + } + resp, err := client.Do(r) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 256<<10)) + return data, resp.StatusCode, err +} + +func brevoErr(op string, status int, body []byte) error { + var e brevoError + if json.Unmarshal(body, &e) == nil && e.Message != "" { + return fmt.Errorf("brevo: %s: HTTP %d %s: %s", op, status, e.Code, e.Message) + } + return fmt.Errorf("brevo: %s: HTTP %d", op, status) +} + +func parseBrevoDate(s string) (time.Time, error) { + s = strings.TrimSpace(s) + for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("brevo: unrecognized date %q", s) +} + +// brevoEventNames maps webhook and events-API spellings to canonical names. +// "bounces" (events API, unspecified kind) is treated as soft so it never +// flags an address on its own. +var brevoEventNames = map[string]string{ + "request": EventSent, "requests": EventSent, + "delivered": EventDelivered, + "opened": EventOpened, "unique_opened": EventOpened, "proxy_open": EventOpened, + "unique_proxy_open": EventOpened, "loadedbyproxy": EventOpened, + "click": EventClicked, "clicks": EventClicked, + "soft_bounce": EventSoftBounce, "softbounces": EventSoftBounce, "bounces": EventSoftBounce, + "hard_bounce": EventHardBounce, "hardbounces": EventHardBounce, + "invalid_email": EventInvalidEmail, "invalid": EventInvalidEmail, + "deferred": EventDeferred, "spam": EventSpam, "blocked": EventBlocked, + "error": EventError, "unsubscribed": EventUnsubscribed, +} + +// NormalizeBrevoEvent maps a Brevo event name to CoreScope's canonical name. +func NormalizeBrevoEvent(name string) string { + key := strings.ToLower(strings.TrimSpace(name)) + if v, ok := brevoEventNames[key]; ok { + return v + } + return key +} diff --git a/internal/mailer/brevo_test.go b/internal/mailer/brevo_test.go new file mode 100644 index 00000000..4ec8a8fa --- /dev/null +++ b/internal/mailer/brevo_test.go @@ -0,0 +1,136 @@ +package mailer + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBrevoSendRequestShape(t *testing.T) { + var gotPath, gotKey string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotKey = r.URL.Path, r.Header.Get("api-key") + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"messageId":"<201798300811.5787683@relay.domain.com>"}`)) + })) + defer srv.Close() + b := NewBrevo("xkeysib-test", "noreply@example.org", "CoreScope") + b.BaseURL = srv.URL + + id, err := b.Send(context.Background(), Message{To: "a@example.org", ToName: "Alice", Subject: "S", + HTML: "

h

", Text: "t", Tag: "activate"}) + if err != nil || id != "<201798300811.5787683@relay.domain.com>" { + t.Fatalf("Send = %q, %v", id, err) + } + if gotPath != "/smtp/email" || gotKey != "xkeysib-test" { + t.Fatalf("path=%q key=%q", gotPath, gotKey) + } + sender := body["sender"].(map[string]any) + to := body["to"].([]any)[0].(map[string]any) + if sender["email"] != "noreply@example.org" || sender["name"] != "CoreScope" || + to["email"] != "a@example.org" || to["name"] != "Alice" || + body["subject"] != "S" || body["htmlContent"] != "

h

" || body["textContent"] != "t" || + body["tags"].([]any)[0] != "activate" { + t.Fatalf("request body = %+v", body) + } +} + +func TestBrevoSendErrorMapping(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"code":"unauthorized","message":"Key not found"}`)) + })) + defer srv.Close() + b := NewBrevo("bad", "noreply@example.org", "") + b.BaseURL = srv.URL + _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t"}) + if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "Key not found") { + t.Fatalf("err = %v", err) + } + if strings.Contains(err.Error(), "bad") { + t.Fatal("API key leaked into the error text") + } +} + +func TestBrevoEvents(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Write([]byte(`{"events":[ + {"date":"2026-10-06T12:00:05.000+02:00","email":"a@example.org","event":"requests","messageId":""}, + {"date":"2026-10-06T12:00:09.000+02:00","email":"a@example.org","event":"hardBounces","messageId":"","reason":"user unknown"}]}`)) + })) + defer srv.Close() + b := NewBrevo("k", "noreply@example.org", "") + b.BaseURL = srv.URL + evs, err := b.Events(context.Background(), "") + if err != nil || len(evs) != 2 { + t.Fatalf("Events = %+v, %v", evs, err) + } + if !strings.Contains(gotQuery, "messageId=%3Cm1%3E") { + t.Fatalf("query = %q", gotQuery) + } + if evs[0].Event != EventSent || evs[1].Event != EventHardBounce || evs[1].Reason != "user unknown" || + evs[1].At.UTC().Hour() != 10 { + t.Fatalf("events = %+v", evs) + } +} + +func TestNormalizeBrevoEvent(t *testing.T) { + cases := map[string]string{ + "request": EventSent, "requests": EventSent, "delivered": EventDelivered, + "opened": EventOpened, "unique_opened": EventOpened, "proxy_open": EventOpened, "loadedByProxy": EventOpened, + "click": EventClicked, "clicks": EventClicked, + "soft_bounce": EventSoftBounce, "softBounces": EventSoftBounce, "bounces": EventSoftBounce, + "hard_bounce": EventHardBounce, "hardBounces": EventHardBounce, + "invalid_email": EventInvalidEmail, "invalid": EventInvalidEmail, + "spam": EventSpam, "blocked": EventBlocked, "deferred": EventDeferred, "error": EventError, + "Something_New": "something_new", + } + for in, want := range cases { + if got := NormalizeBrevoEvent(in); got != want { + t.Errorf("NormalizeBrevoEvent(%q) = %q; want %q", in, got, want) + } + } +} + +func TestBrevoDoesNotFollowRedirects(t *testing.T) { + var leaked string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + leaked = r.Header.Get("api-key") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"messageId":""}`)) + })) + defer target.Close() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/smtp/email", http.StatusFound) + })) + defer srv.Close() + b := NewBrevo("secret-key", "noreply@example.org", "") + b.BaseURL = srv.URL + if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t"}); err == nil { + t.Fatal("Send followed a redirect") + } + if leaked != "" { + t.Fatalf("api-key reached the redirect target: %q", leaked) + } +} + +func TestBrevoSendRejectsEmptyContentAndNilClient(t *testing.T) { + b := &Brevo{APIKey: "k", FromEmail: "f@example.org", BaseURL: "http://127.0.0.1:1"} + if _, err := b.Send(context.Background(), Message{To: "a@example.org"}); err == nil || + !strings.Contains(err.Error(), "no content") { + t.Fatalf("err = %v", err) + } + // nil HTTP client must not panic (connection error expected). + if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t"}); err == nil { + t.Fatal("expected connection error") + } +} diff --git a/internal/mailer/brevo_webhook.go b/internal/mailer/brevo_webhook.go new file mode 100644 index 00000000..e3ae09ca --- /dev/null +++ b/internal/mailer/brevo_webhook.go @@ -0,0 +1,60 @@ +package mailer + +import ( + "bytes" + "encoding/json" + "errors" + "time" +) + +type brevoWebhookEvent struct { + Event string `json:"event"` + Email string `json:"email"` + MessageID string `json:"message-id"` + Reason string `json:"reason"` + Date string `json:"date"` + Ts int64 `json:"ts"` + TsEvent int64 `json:"ts_event"` +} + +// ParseBrevoWebhook parses a transactional webhook body: one event object or, +// for batched webhooks, an array. Entries without a message id or event name +// are skipped; an error is returned if nothing usable remains. +func ParseBrevoWebhook(body []byte) ([]Event, error) { + trimmed := bytes.TrimSpace(body) + var raw []brevoWebhookEvent + if len(trimmed) > 0 && trimmed[0] == '[' { + if err := json.Unmarshal(trimmed, &raw); err != nil { + return nil, err + } + } else { + var one brevoWebhookEvent + if err := json.Unmarshal(trimmed, &one); err != nil { + return nil, err + } + raw = append(raw, one) + } + out := make([]Event, 0, len(raw)) + for _, e := range raw { + if e.MessageID == "" || e.Event == "" { + continue + } + var at time.Time + switch { + case e.TsEvent > 0: + at = time.Unix(e.TsEvent, 0).UTC() + case e.Ts > 0: + at = time.Unix(e.Ts, 0).UTC() + default: + at, _ = parseBrevoDate(e.Date) + } + if at.IsZero() { + at = time.Now().UTC() + } + out = append(out, Event{MessageID: e.MessageID, Email: e.Email, Event: NormalizeBrevoEvent(e.Event), Reason: e.Reason, At: at}) + } + if len(out) == 0 { + return nil, errors.New("brevo webhook: no usable events") + } + return out, nil +} diff --git a/internal/mailer/brevo_webhook_test.go b/internal/mailer/brevo_webhook_test.go new file mode 100644 index 00000000..66cc91fc --- /dev/null +++ b/internal/mailer/brevo_webhook_test.go @@ -0,0 +1,41 @@ +package mailer + +import ( + "testing" + "time" +) + +func TestParseBrevoWebhookSingle(t *testing.T) { + body := []byte(`{"event":"hard_bounce","email":"a@example.org","id":1,"date":"2026-10-06 12:00:00", + "ts":1791288000,"message-id":"","ts_event":1791288005,"reason":"user unknown","subject":"x"}`) + evs, err := ParseBrevoWebhook(body) + if err != nil || len(evs) != 1 { + t.Fatalf("ParseBrevoWebhook = %+v, %v", evs, err) + } + e := evs[0] + if e.MessageID != "" || e.Event != EventHardBounce || e.Reason != "user unknown" || + !e.At.Equal(time.Unix(1791288005, 0).UTC()) { + t.Fatalf("event = %+v", e) + } +} + +func TestParseBrevoWebhookBatchAndFallbacks(t *testing.T) { + body := []byte(`[{"event":"delivered","message-id":"","ts":1791288000}, + {"event":"unique_opened","message-id":"","date":"2026-10-06 12:00:00"}, + {"event":"delivered","email":"no-id@example.org"}]`) + evs, err := ParseBrevoWebhook(body) + if err != nil || len(evs) != 2 { + t.Fatalf("batch = %+v, %v", evs, err) + } + if evs[0].At.Unix() != 1791288000 || evs[1].Event != EventOpened || evs[1].At.IsZero() { + t.Fatalf("batch events = %+v", evs) + } +} + +func TestParseBrevoWebhookRejectsGarbage(t *testing.T) { + for _, body := range []string{``, `not json`, `{}`, `[]`, `{"event":"delivered"}`} { + if _, err := ParseBrevoWebhook([]byte(body)); err == nil { + t.Errorf("ParseBrevoWebhook(%q) accepted", body) + } + } +} diff --git a/internal/mailer/fake.go b/internal/mailer/fake.go new file mode 100644 index 00000000..f57f11e8 --- /dev/null +++ b/internal/mailer/fake.go @@ -0,0 +1,68 @@ +package mailer + +import ( + "context" + "fmt" + "sync" +) + +// Fake records sent messages in memory. Safe for concurrent use. +type Fake struct { + mu sync.Mutex + sent []Message + ids []string + sendErr error + events map[string][]Event +} + +func (f *Fake) Send(_ context.Context, m Message) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.sendErr != nil { + return "", f.sendErr + } + id := fmt.Sprintf("", len(f.sent)+1) + f.sent = append(f.sent, m) + f.ids = append(f.ids, id) + return id, nil +} + +func (f *Fake) Events(_ context.Context, messageID string) ([]Event, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Event(nil), f.events[messageID]...), nil +} + +// SetSendErr makes subsequent Sends fail with err (nil restores success). +func (f *Fake) SetSendErr(err error) { + f.mu.Lock() + f.sendErr = err + f.mu.Unlock() +} + +// SetEvents sets what Events returns for messageID. +func (f *Fake) SetEvents(messageID string, evs []Event) { + f.mu.Lock() + defer f.mu.Unlock() + if f.events == nil { + f.events = map[string][]Event{} + } + f.events[messageID] = append([]Event(nil), evs...) +} + +// Sent returns a copy of all successfully sent messages. +func (f *Fake) Sent() []Message { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Message(nil), f.sent...) +} + +// Last returns the most recent message and its id. +func (f *Fake) Last() (Message, string, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.sent) == 0 { + return Message{}, "", false + } + return f.sent[len(f.sent)-1], f.ids[len(f.ids)-1], true +} diff --git a/internal/mailer/fake_test.go b/internal/mailer/fake_test.go new file mode 100644 index 00000000..ae72db7b --- /dev/null +++ b/internal/mailer/fake_test.go @@ -0,0 +1,35 @@ +package mailer + +import ( + "context" + "errors" + "testing" +) + +func TestFakeRecordsAndFails(t *testing.T) { + f := &Fake{} + id, err := f.Send(context.Background(), Message{To: "a@example.org", Subject: "Hi", Text: "link"}) + if err != nil || id == "" { + t.Fatalf("Send = %q, %v", id, err) + } + m, gotID, ok := f.Last() + if !ok || m.To != "a@example.org" || gotID != id { + t.Fatalf("Last = %+v %q %v", m, gotID, ok) + } + f.SetEvents(id, []Event{{MessageID: id, Event: EventDelivered}}) + evs, _ := f.Events(context.Background(), id) + if len(evs) != 1 || evs[0].Event != EventDelivered { + t.Fatalf("Events = %+v", evs) + } + boom := errors.New("down") + f.SetSendErr(boom) + if _, err := f.Send(context.Background(), Message{To: "b@example.org"}); !errors.Is(err, boom) { + t.Fatalf("Send with error = %v", err) + } + if len(f.Sent()) != 1 { + t.Fatalf("failed send was recorded: %d", len(f.Sent())) + } + if !IsUndeliverable(EventHardBounce) || !IsUndeliverable(EventInvalidEmail) || IsUndeliverable(EventSoftBounce) { + t.Fatal("IsUndeliverable classification wrong") + } +} diff --git a/internal/mailer/go.mod b/internal/mailer/go.mod new file mode 100644 index 00000000..20329573 --- /dev/null +++ b/internal/mailer/go.mod @@ -0,0 +1,3 @@ +module github.com/meshcore-analyzer/mailer + +go 1.22 diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 00000000..a75db730 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,54 @@ +// Package mailer sends CoreScope's account mails and reads back delivery +// events. Brevo is the default provider; Fake is the in-memory test double. +package mailer + +import ( + "context" + "time" +) + +// Message is one outgoing mail. HTML and Text carry the same content. +type Message struct { + To string + ToName string + Subject string + HTML string + Text string + Tag string // provider tag, e.g. "activate" +} + +// Canonical delivery event names stored by CoreScope, independent of provider. +const ( + EventSent = "sent" + EventDelivered = "delivered" + EventOpened = "opened" + EventClicked = "clicked" + EventSoftBounce = "soft_bounce" + EventHardBounce = "hard_bounce" + EventInvalidEmail = "invalid_email" + EventDeferred = "deferred" + EventSpam = "spam" + EventBlocked = "blocked" + EventError = "error" + EventUnsubscribed = "unsubscribed" +) + +// Event is one delivery event for a sent message. +type Event struct { + MessageID string + Email string + Event string // canonical name + Reason string + At time.Time +} + +// Mailer sends mail and can report delivery events for a message id. +type Mailer interface { + Send(ctx context.Context, m Message) (messageID string, err error) + Events(ctx context.Context, messageID string) ([]Event, error) +} + +// IsUndeliverable reports events after which an address should be flagged. +func IsUndeliverable(event string) bool { + return event == EventHardBounce || event == EventInvalidEmail +} diff --git a/internal/users/audit.go b/internal/users/audit.go new file mode 100644 index 00000000..c637a18a --- /dev/null +++ b/internal/users/audit.go @@ -0,0 +1,67 @@ +package users + +import ( + "database/sql" + "encoding/json" + "time" +) + +// AuditEntry is one recorded action. Rows outlive the users they mention. +type AuditEntry struct { + ID int64 + At time.Time + ActorUserID *int64 // nil = system or API key + Action string + TargetUserID *int64 + Detail map[string]string +} + +// Audit records an action. detail may be nil. +func (s *Store) Audit(actor *int64, action string, target *int64, detail map[string]string) error { + if detail == nil { + detail = map[string]string{} + } + b, err := json.Marshal(detail) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO audit_log (at, actor_user_id, action, target_user_id, detail) VALUES (?, ?, ?, ?, ?)`, + unix(s.now()), nullInt(actor), action, nullInt(target), string(b)) + return err +} + +// AuditFor returns entries where userID is the target or the actor, newest first. +func (s *Store) AuditFor(userID int64, limit int) ([]AuditEntry, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.Query(`SELECT id, at, actor_user_id, action, target_user_id, detail FROM audit_log + WHERE target_user_id = ? OR actor_user_id = ? ORDER BY at DESC, id DESC LIMIT ?`, userID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AuditEntry + for rows.Next() { + var e AuditEntry + var at int64 + var actor, target sql.NullInt64 + var detail string + if err := rows.Scan(&e.ID, &at, &actor, &e.Action, &target, &detail); err != nil { + return nil, err + } + e.At = fromUnix(at) + if actor.Valid { + v := actor.Int64 + e.ActorUserID = &v + } + if target.Valid { + v := target.Int64 + e.TargetUserID = &v + } + e.Detail = map[string]string{} + _ = json.Unmarshal([]byte(detail), &e.Detail) + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/users/audit_test.go b/internal/users/audit_test.go new file mode 100644 index 00000000..7430939f --- /dev/null +++ b/internal/users/audit_test.go @@ -0,0 +1,35 @@ +package users + +import "testing" + +func TestAuditWriteAndRead(t *testing.T) { + st, clk := newTestStore(t) + admin := mustCreate(t, st, "a@example.org", "Adm") + u := mustCreate(t, st, "u@example.org", "Usr") + aid, uid := admin.ID, u.ID + if err := st.Audit(&aid, "user.disable", &uid, map[string]string{"reason": "spam"}); err != nil { + t.Fatal(err) + } + if err := st.Audit(nil, "user.register", &uid, nil); err != nil { + t.Fatal(err) + } + entries, err := st.AuditFor(u.ID, 50) + if err != nil || len(entries) != 2 { + t.Fatalf("AuditFor = %d, %v", len(entries), err) + } + var disable *AuditEntry + for i := range entries { + if entries[i].Action == "user.disable" { + disable = &entries[i] + } + } + if disable == nil || disable.ActorUserID == nil || *disable.ActorUserID != aid || + disable.Detail["reason"] != "spam" || !disable.At.Equal(clk.Now()) { + t.Fatalf("disable entry = %+v", disable) + } + // Entries survive the target's deletion. + st.Delete(u.ID) + if entries, _ := st.AuditFor(uid, 50); len(entries) != 2 { + t.Fatalf("audit rows lost on delete: %d", len(entries)) + } +} diff --git a/internal/users/go.mod b/internal/users/go.mod new file mode 100644 index 00000000..ac4edb30 --- /dev/null +++ b/internal/users/go.mod @@ -0,0 +1,20 @@ +module github.com/meshcore-analyzer/users + +go 1.22 + +require ( + golang.org/x/crypto v0.31.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + 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.28.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/internal/users/go.sum b/internal/users/go.sum new file mode 100644 index 00000000..aa3cb2b4 --- /dev/null +++ b/internal/users/go.sum @@ -0,0 +1,45 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/users/helpers_test.go b/internal/users/helpers_test.go new file mode 100644 index 00000000..e6efef15 --- /dev/null +++ b/internal/users/helpers_test.go @@ -0,0 +1,25 @@ +package users + +import ( + "path/filepath" + "testing" + "time" +) + +type fakeClock struct{ t time.Time } + +func (c *fakeClock) Now() time.Time { return c.t } +func (c *fakeClock) Advance(d time.Duration) { c.t = c.t.Add(d) } + +// newTestStore opens a fresh users.db in a temp dir with a controllable clock. +func newTestStore(t *testing.T) (*Store, *fakeClock) { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + clk := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} + st.SetClock(clk.Now) + t.Cleanup(func() { st.Close() }) + return st, clk +} diff --git a/internal/users/mail.go b/internal/users/mail.go new file mode 100644 index 00000000..1ec4897e --- /dev/null +++ b/internal/users/mail.go @@ -0,0 +1,193 @@ +package users + +import ( + "database/sql" + "errors" + "time" +) + +// MailEvent is one provider delivery event. +type MailEvent struct { + Event string + At time.Time + Reason string +} + +// MailRecord is one sent mail with its delivery summary and history. +type MailRecord struct { + ID int64 + UserID *int64 + ToEmail string + Purpose string + ProviderMessageID string + SentAt time.Time + LastEvent string + LastEventAt time.Time + LastReason string + Events []MailEvent // chronological; filled by MailByID / MailForUser +} + +const mailCols = `id, user_id, to_email, purpose, provider_message_id, sent_at, last_event, last_event_at, last_reason` + +func scanMail(row rowScanner) (*MailRecord, error) { + var m MailRecord + var uid sql.NullInt64 + var msgID sql.NullString + var sent, lastAt int64 + if err := row.Scan(&m.ID, &uid, &m.ToEmail, &m.Purpose, &msgID, &sent, &m.LastEvent, &lastAt, &m.LastReason); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if uid.Valid { + v := uid.Int64 + m.UserID = &v + } + m.ProviderMessageID = msgID.String + m.SentAt, m.LastEventAt = fromUnix(sent), fromUnix(lastAt) + return &m, nil +} + +// LogMail records a sent mail. messageID may be empty if the provider gave none. +func (s *Store) LogMail(userID *int64, to, purpose, messageID string) (int64, error) { + now := unix(s.now()) + var mid any + if messageID != "" { + mid = messageID + } + res, err := s.db.Exec(`INSERT INTO mail_log (user_id, to_email, purpose, provider_message_id, sent_at, last_event, last_event_at) + VALUES (?, ?, ?, ?, ?, 'sent', ?)`, nullInt(userID), to, purpose, mid, now, now) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// RecordMailEvent appends a provider event to the mail with that provider +// message id. Duplicates (same event and time) are ignored; the summary +// columns follow the newest event. found is false for unknown ids. +func (s *Store) RecordMailEvent(messageID, event string, at time.Time, reason string) (userID *int64, found bool, err error) { + tx, err := s.db.Begin() + if err != nil { + return nil, false, err + } + defer tx.Rollback() + var id, lastAt int64 + var uid sql.NullInt64 + err = tx.QueryRow(`SELECT id, user_id, last_event_at FROM mail_log WHERE provider_message_id = ?`, messageID). + Scan(&id, &uid, &lastAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO mail_events (mail_id, event, at, reason) VALUES (?, ?, ?, ?)`, + id, event, unix(at), reason); err != nil { + return nil, false, err + } + if unix(at) >= lastAt { + if _, err := tx.Exec(`UPDATE mail_log SET last_event = ?, last_event_at = ?, last_reason = ? WHERE id = ?`, + event, unix(at), reason, id); err != nil { + return nil, false, err + } + } + if err := tx.Commit(); err != nil { + return nil, false, err + } + if uid.Valid { + v := uid.Int64 + userID = &v + } + return userID, true, nil +} + +func (s *Store) eventsFor(mailID int64) ([]MailEvent, error) { + rows, err := s.db.Query(`SELECT event, at, reason FROM mail_events WHERE mail_id = ? ORDER BY at, rowid`, mailID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailEvent + for rows.Next() { + var e MailEvent + var at int64 + if err := rows.Scan(&e.Event, &at, &e.Reason); err != nil { + return nil, err + } + e.At = fromUnix(at) + out = append(out, e) + } + return out, rows.Err() +} + +// MailByID returns one record with its events. +func (s *Store) MailByID(id int64) (*MailRecord, error) { + m, err := scanMail(s.db.QueryRow(`SELECT `+mailCols+` FROM mail_log WHERE id = ?`, id)) + if err != nil { + return nil, err + } + if m.Events, err = s.eventsFor(m.ID); err != nil { + return nil, err + } + return m, nil +} + +// MailForUser returns a user's mails, newest first, each with its events. +func (s *Store) MailForUser(userID int64, limit int) ([]MailRecord, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.Query(`SELECT `+mailCols+` FROM mail_log WHERE user_id = ? ORDER BY sent_at DESC, id DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + var out []MailRecord + for rows.Next() { + m, err := scanMail(rows) + if err != nil { + rows.Close() + return nil, err + } + out = append(out, *m) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + if out[i].Events, err = s.eventsFor(out[i].ID); err != nil { + return nil, err + } + } + return out, nil +} + +// LatestMailByUser maps user id → that user's most recent mail (no events). +func (s *Store) LatestMailByUser() (map[int64]MailRecord, error) { + rows, err := s.db.Query(`SELECT ` + mailCols + ` FROM mail_log m WHERE user_id IS NOT NULL + AND id = (SELECT MAX(id) FROM mail_log WHERE user_id = m.user_id)`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]MailRecord{} + for rows.Next() { + m, err := scanMail(rows) + if err != nil { + return nil, err + } + out[*m.UserID] = *m + } + return out, rows.Err() +} + +// PruneMail deletes mail records (and their events) older than maxAge. +func (s *Store) PruneMail(maxAge time.Duration) (int64, error) { + res, err := s.db.Exec(`DELETE FROM mail_log WHERE sent_at < ?`, unix(s.now())-int64(maxAge/time.Second)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/users/mail_test.go b/internal/users/mail_test.go new file mode 100644 index 00000000..3041a2d0 --- /dev/null +++ b/internal/users/mail_test.go @@ -0,0 +1,135 @@ +package users + +import ( + "errors" + "testing" + "time" +) + +func TestMailEventsSummaryFollowsNewest(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "m@example.org", "Mai") + uid := u.ID + id, err := st.LogMail(&uid, "m@example.org", "activate", "") + if err != nil { + t.Fatal(err) + } + t0 := clk.Now() + if _, found, err := st.RecordMailEvent("", "delivered", t0.Add(time.Minute), ""); err != nil || !found { + t.Fatalf("delivered: %v %v", found, err) + } + gotUID, found, err := st.RecordMailEvent("", "hard_bounce", t0.Add(3*time.Minute), "mailbox full") + if err != nil || !found || gotUID == nil || *gotUID != uid { + t.Fatalf("hard_bounce: %v %v %v", gotUID, found, err) + } + // Older event arriving late does not overwrite the summary. + st.RecordMailEvent("", "opened", t0.Add(2*time.Minute), "") + // Duplicate is ignored. + st.RecordMailEvent("", "delivered", t0.Add(time.Minute), "") + + rec, err := st.MailByID(id) + if err != nil { + t.Fatal(err) + } + if rec.LastEvent != "hard_bounce" || rec.LastReason != "mailbox full" || len(rec.Events) != 3 { + t.Fatalf("record = %+v", rec) + } + if rec.Events[0].Event != "delivered" { // chronological + t.Fatalf("events not chronological: %+v", rec.Events) + } + if _, found, _ := st.RecordMailEvent("", "delivered", t0, ""); found { + t.Fatal("unknown message id reported as found") + } +} + +func TestMailForUserAndLatest(t *testing.T) { + st, clk := newTestStore(t) + a := mustCreate(t, st, "a@example.org", "Aaa") + b := mustCreate(t, st, "b@example.org", "Bbb") + aid, bid := a.ID, b.ID + st.LogMail(&aid, "a@example.org", "activate", "") + clk.Advance(time.Minute) + st.LogMail(&aid, "a@example.org", "reset", "") + st.LogMail(&bid, "b@example.org", "activate", "") + + list, err := st.MailForUser(a.ID, 10) + if err != nil || len(list) != 2 || list[0].Purpose != "reset" { + t.Fatalf("MailForUser = %+v, %v", list, err) + } + latest, err := st.LatestMailByUser() + if err != nil || latest[a.ID].Purpose != "reset" || latest[b.ID].Purpose != "activate" { + t.Fatalf("LatestMailByUser = %+v, %v", latest, err) + } +} + +func TestPruneMail(t *testing.T) { + st, clk := newTestStore(t) + st.LogMail(nil, "x@example.org", "activate", "") + clk.Advance(91 * 24 * time.Hour) + st.LogMail(nil, "y@example.org", "activate", "") + n, err := st.PruneMail(90 * 24 * time.Hour) + if err != nil || n != 1 { + t.Fatalf("PruneMail = %d, %v", n, err) + } +} + +func TestDeleteHashesMailAddresses(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "gone@example.org", "Gone") + uid := u.ID + mailID, err := st.LogMail(&uid, "gone@example.org", "activate", "") + if err != nil { + t.Fatal(err) + } + if err := st.Delete(u.ID); err != nil { + t.Fatal(err) + } + if _, err := st.GetByID(u.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("user still present: %v", err) + } + rec, err := st.MailByID(mailID) + if err != nil { + t.Fatal(err) + } + if rec.UserID != nil || rec.ToEmail != HashedEmail("gone@example.org") { + t.Fatalf("mail record after delete: %+v", rec) + } + if err := st.Delete(u.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("second Delete = %v", err) + } +} + +func TestPruneStalePendingHashesMailAddresses(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "stale@example.org", "Stale") + uid := u.ID + id, err := st.LogMail(&uid, "stale@example.org", "activate", "") + if err != nil { + t.Fatal(err) + } + clk.Advance(72 * time.Hour) + if n, err := st.PruneStalePending(48 * time.Hour); err != nil || n != 1 { + t.Fatalf("PruneStalePending = %d, %v", n, err) + } + rec, err := st.MailByID(id) + if err != nil { + t.Fatal(err) + } + if rec.UserID != nil || rec.ToEmail != HashedEmail("stale@example.org") { + t.Fatalf("mail record after prune: %+v", rec) + } +} + +func TestDeleteHashesEachMailRowOwnAddress(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "new@example.org", "Chg") + uid := u.ID + id, _ := st.LogMail(&uid, "old@example.org", "activate", "") + if err := st.Delete(u.ID); err != nil { + t.Fatal(err) + } + rec, _ := st.MailByID(id) + if rec.ToEmail != HashedEmail("old@example.org") { + t.Fatalf("ToEmail = %q", rec.ToEmail) + } +} diff --git a/internal/users/onetime.go b/internal/users/onetime.go new file mode 100644 index 00000000..c1af9bee --- /dev/null +++ b/internal/users/onetime.go @@ -0,0 +1,159 @@ +package users + +import ( + "database/sql" + "errors" + "time" +) + +// Purpose is what a one-time link may be used for. +type Purpose string + +const ( + PurposeActivate Purpose = "activate" + PurposeReset Purpose = "reset" + PurposeEmailChange Purpose = "email_change" +) + +// IssueToken creates a one-time token and invalidates the user's earlier +// unused tokens for the same purpose, so only the newest link works. +// newEmail is stored for PurposeEmailChange and ignored when empty. +func (s *Store) IssueToken(userID int64, p Purpose, ttl time.Duration, newEmail string) (string, error) { + raw, hash, err := NewToken() + if err != nil { + return "", err + } + now := s.now() + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if _, err := tx.Exec(`UPDATE tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL`, + unix(now), userID, string(p)); err != nil { + return "", err + } + var ne any + if newEmail != "" { + ne = newEmail + } + if _, err := tx.Exec(`INSERT INTO tokens (token_hash, user_id, purpose, new_email, expires_at) VALUES (?, ?, ?, ?, ?)`, + hash, userID, string(p), ne, unix(now.Add(ttl))); err != nil { + return "", err + } + return raw, tx.Commit() +} + +// tokenQuerier is satisfied by *sql.DB and *sql.Tx. +type tokenQuerier interface { + QueryRow(query string, args ...any) *sql.Row +} + +// checkToken validates an unused, unexpired token of purpose p by hash +// without changing anything. +func (s *Store) checkToken(q tokenQuerier, hash string, p Purpose) (userID int64, newEmail string, err error) { + var purpose string + var expires int64 + var ne sql.NullString + var used sql.NullInt64 + err = q.QueryRow(`SELECT user_id, purpose, new_email, expires_at, used_at FROM tokens WHERE token_hash = ?`, hash). + Scan(&userID, &purpose, &ne, &expires, &used) + if errors.Is(err, sql.ErrNoRows) { + return 0, "", ErrTokenInvalid + } + if err != nil { + return 0, "", err + } + if purpose != string(p) || used.Valid { + return 0, "", ErrTokenInvalid + } + if unix(s.now()) >= expires { + return 0, "", ErrTokenExpired + } + return userID, ne.String, nil +} + +// TokenUser returns the user a token belongs to with the same checks as +// ConsumeToken, but does not burn it. +func (s *Store) TokenUser(raw string, p Purpose) (int64, error) { + uid, _, err := s.checkToken(s.db, HashToken(raw), p) + return uid, err +} + +// ConsumeToken validates and burns a token. A purpose mismatch returns +// ErrTokenInvalid without burning it. Expired tokens return ErrTokenExpired. +func (s *Store) ConsumeToken(raw string, p Purpose) (userID int64, newEmail string, err error) { + hash := HashToken(raw) + tx, err := s.db.Begin() + if err != nil { + return 0, "", err + } + defer tx.Rollback() + userID, newEmail, err = s.checkToken(tx, hash, p) + if err != nil { + return 0, "", err + } + if err := expectOne(tx.Exec(`UPDATE tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`, unix(s.now()), hash)); err != nil { + if errors.Is(err, ErrNotFound) { + return 0, "", ErrTokenInvalid + } + return 0, "", err + } + if err := tx.Commit(); err != nil { + return 0, "", err + } + return userID, newEmail, nil +} + +// ActivateWithToken burns an activation token of user id and activates the +// account with role in one transaction, but only while the user is still +// pending with verifiedHash, the hash the caller checked the password +// against. Otherwise it returns ErrAccountChanged and burns nothing. +// A token of another purpose or user returns ErrTokenInvalid. +func (s *Store) ActivateWithToken(raw string, id int64, role Role, verifiedHash string) error { + hash := HashToken(raw) + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + uid, _, err := s.checkToken(tx, hash, PurposeActivate) + if err != nil { + return err + } + if uid != id { + return ErrTokenInvalid + } + now := unix(s.now()) + if err := expectOne(tx.Exec(`UPDATE tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`, now, hash)); err != nil { + if errors.Is(err, ErrNotFound) { + return ErrTokenInvalid + } + return err + } + err = expectOne(tx.Exec(`UPDATE users SET status = 'active', role = ?, activated_at = ?, activated_by = NULL + WHERE id = ? AND status = 'pending' AND password_hash = ?`, string(role), now, id, verifiedHash)) + if errors.Is(err, ErrNotFound) { + return ErrAccountChanged + } + if err != nil { + return err + } + return tx.Commit() +} + +// InvalidateTokens burns all of a user's unused tokens for purpose p. +func (s *Store) InvalidateTokens(userID int64, p Purpose) error { + _, err := s.db.Exec(`UPDATE tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL`, + unix(s.now()), userID, string(p)) + return err +} + +// PruneTokens deletes tokens that expired more than keep ago. +func (s *Store) PruneTokens(keep time.Duration) (int64, error) { + res, err := s.db.Exec(`DELETE FROM tokens WHERE expires_at < ?`, unix(s.now())-int64(keep/time.Second)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/users/onetime_test.go b/internal/users/onetime_test.go new file mode 100644 index 00000000..15653d69 --- /dev/null +++ b/internal/users/onetime_test.go @@ -0,0 +1,222 @@ +package users + +import ( + "errors" + "testing" + "time" +) + +func TestTokenConsumeOnce(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "t@example.org", "Tok") + raw, err := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + if err != nil { + t.Fatal(err) + } + uid, newEmail, err := st.ConsumeToken(raw, PurposeActivate) + if err != nil || uid != u.ID || newEmail != "" { + t.Fatalf("ConsumeToken = %d, %q, %v", uid, newEmail, err) + } + if _, _, err := st.ConsumeToken(raw, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("second consume err = %v", err) + } +} + +func TestTokenPurposeMismatchDoesNotConsume(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "m@example.org", "Mis") + raw, _ := st.IssueToken(u.ID, PurposeReset, time.Hour, "") + if _, _, err := st.ConsumeToken(raw, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("mismatch err = %v", err) + } + if _, _, err := st.ConsumeToken(raw, PurposeReset); err != nil { + t.Fatalf("token was consumed by the mismatched attempt: %v", err) + } +} + +func TestTokenExpiry(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "e@example.org", "Exp") + raw, _ := st.IssueToken(u.ID, PurposeReset, time.Hour, "") + clk.Advance(time.Hour) + if _, _, err := st.ConsumeToken(raw, PurposeReset); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expired err = %v", err) + } +} + +func TestNewTokenInvalidatesOlderSamePurpose(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "n@example.org", "New") + old, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + keep, _ := st.IssueToken(u.ID, PurposeReset, time.Hour, "") + fresh, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + if _, _, err := st.ConsumeToken(old, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("old activation link still works: %v", err) + } + if _, _, err := st.ConsumeToken(fresh, PurposeActivate); err != nil { + t.Fatalf("fresh link: %v", err) + } + if _, _, err := st.ConsumeToken(keep, PurposeReset); err != nil { + t.Fatalf("other purpose was invalidated: %v", err) + } +} + +func TestEmailChangeTokenCarriesNewEmail(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "c@example.org", "Chg") + raw, _ := st.IssueToken(u.ID, PurposeEmailChange, time.Hour, "new@example.org") + _, ne, err := st.ConsumeToken(raw, PurposeEmailChange) + if err != nil || ne != "new@example.org" { + t.Fatalf("ConsumeToken = %q, %v", ne, err) + } +} + +func TestInvalidateAndPruneTokens(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "i@example.org", "Inv") + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + if err := st.InvalidateTokens(u.ID, PurposeActivate); err != nil { + t.Fatal(err) + } + if _, _, err := st.ConsumeToken(raw, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("invalidated token still works: %v", err) + } + clk.Advance(8 * 24 * time.Hour) + if n, err := st.PruneTokens(7 * 24 * time.Hour); err != nil || n != 1 { + t.Fatalf("PruneTokens = %d, %v", n, err) + } +} + +func TestPruneStalePending(t *testing.T) { + st, clk := newTestStore(t) + stale := mustCreate(t, st, "stale@example.org", "Stale") + st.IssueToken(stale.ID, PurposeActivate, 48*time.Hour, "") + fresh := mustCreate(t, st, "fresh@example.org", "Fresh") + _ = fresh + clk.Advance(49 * time.Hour) + resent := mustCreate(t, st, "resent@example.org", "Resent") + _ = resent + n, err := st.PruneStalePending(48 * time.Hour) + if err != nil { + t.Fatal(err) + } + // stale: old + token expired → pruned. fresh: old, no token → pruned. + // resent: created after the advance → kept. + if n != 2 { + t.Fatalf("pruned %d; want 2", n) + } + if _, err := st.GetByEmail("resent@example.org"); err != nil { + t.Fatalf("recent pending pruned: %v", err) + } + // An old pending account with a live (re-sent) token survives. + old := mustCreate(t, st, "old@example.org", "Old") + clk.Advance(49 * time.Hour) + st.IssueToken(old.ID, PurposeActivate, 48*time.Hour, "") + if n, _ := st.PruneStalePending(48 * time.Hour); n != 1 { // only "resent" goes now + t.Fatalf("second prune = %d; want 1", n) + } + if _, err := st.GetByEmail("old@example.org"); err != nil { + t.Fatal("pending user with a live token was pruned") + } +} + +func TestTokenUserDoesNotConsume(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "look@example.org", "Look") + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + for i := 0; i < 2; i++ { + uid, err := st.TokenUser(raw, PurposeActivate) + if err != nil || uid != u.ID { + t.Fatalf("TokenUser #%d = %d, %v", i, uid, err) + } + } + if _, err := st.TokenUser(raw, PurposeReset); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("purpose mismatch err = %v", err) + } + if _, err := st.TokenUser("not-a-token", PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("unknown token err = %v", err) + } + if uid, _, err := st.ConsumeToken(raw, PurposeActivate); err != nil || uid != u.ID { + t.Fatalf("token burned by TokenUser: %d, %v", uid, err) + } + if _, err := st.TokenUser(raw, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("used token err = %v", err) + } + exp, _ := st.IssueToken(u.ID, PurposeReset, time.Hour, "") + clk.Advance(time.Hour) + if _, err := st.TokenUser(exp, PurposeReset); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expired err = %v", err) + } +} + +func TestActivateWithTokenActivatesAndBurns(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "act@example.org", "Act") + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + if err := st.ActivateWithToken(raw, u.ID, RoleAdmin, u.PasswordHash); err != nil { + t.Fatal(err) + } + got, _ := st.GetByID(u.ID) + if got.Status != StatusActive || got.Role != RoleAdmin || got.ActivatedAt == nil || got.ActivatedBy != nil { + t.Fatalf("after ActivateWithToken: %+v", got) + } + if _, err := st.TokenUser(raw, PurposeActivate); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("token still usable after activation: %v", err) + } +} + +func TestActivateWithTokenHashChangedIsAccountChanged(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "race@example.org", "Race") + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + verified := u.PasswordHash + if err := st.SetPassword(u.ID, "$argon2id$replaced"); err != nil { // a re-register in between + t.Fatal(err) + } + if err := st.ActivateWithToken(raw, u.ID, RoleUser, verified); !errors.Is(err, ErrAccountChanged) { + t.Fatalf("err = %v, want ErrAccountChanged", err) + } + if got, _ := st.GetByID(u.ID); got.Status != StatusPending { + t.Fatalf("activated with a stale hash: %+v", got) + } + if _, err := st.TokenUser(raw, PurposeActivate); err != nil { + t.Fatalf("token burned although nothing was activated: %v", err) + } +} + +func TestActivateWithTokenNotPendingIsAccountChanged(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "manual@example.org", "Manual") + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + if err := st.Activate(u.ID, RoleUser, nil); err != nil { // an admin activated in between + t.Fatal(err) + } + if err := st.ActivateWithToken(raw, u.ID, RoleAdmin, u.PasswordHash); !errors.Is(err, ErrAccountChanged) { + t.Fatalf("err = %v, want ErrAccountChanged", err) + } + if got, _ := st.GetByID(u.ID); got.Role != RoleUser { + t.Fatalf("role changed on an active account: %+v", got) + } +} + +func TestActivateWithTokenRejectsBadTokens(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "bad@example.org", "Bad") + other := mustCreate(t, st, "other@example.org", "Other") + reset, _ := st.IssueToken(u.ID, PurposeReset, time.Hour, "") + if err := st.ActivateWithToken(reset, u.ID, RoleUser, u.PasswordHash); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("reset token: err = %v, want ErrTokenInvalid", err) + } + foreign, _ := st.IssueToken(other.ID, PurposeActivate, time.Hour, "") + if err := st.ActivateWithToken(foreign, u.ID, RoleUser, u.PasswordHash); !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("other user's token: err = %v, want ErrTokenInvalid", err) + } + raw, _ := st.IssueToken(u.ID, PurposeActivate, time.Hour, "") + clk.Advance(2 * time.Hour) + if err := st.ActivateWithToken(raw, u.ID, RoleUser, u.PasswordHash); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expired token: err = %v, want ErrTokenExpired", err) + } + if got, _ := st.GetByID(u.ID); got.Status != StatusPending { + t.Fatalf("activated by a bad token: %+v", got) + } +} diff --git a/internal/users/password.go b/internal/users/password.go new file mode 100644 index 00000000..0c850cf0 --- /dev/null +++ b/internal/users/password.go @@ -0,0 +1,83 @@ +package users + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + + "golang.org/x/crypto/argon2" +) + +// argon2id parameters: OWASP's minimum recommendation (19 MiB, 2 passes, +// 1 lane). They are encoded in every hash, so raising them later only +// affects new hashes. +const ( + argonTime uint32 = 2 + argonMemory uint32 = 19 * 1024 + argonThreads uint8 = 1 + argonKeyLen uint32 = 32 + argonSaltLen = 16 +) + +// HashPassword returns a PHC-format argon2id hash. +func HashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("users: salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + b64 := base64.RawStdEncoding + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + b64.EncodeToString(salt), b64.EncodeToString(key)), nil +} + +// VerifyPassword checks password against a PHC argon2id hash in constant time. +func VerifyPassword(encoded, password string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return false, errors.New("users: unsupported password hash format") + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return false, errors.New("users: unsupported argon2 version") + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false, fmt.Errorf("users: bad argon2 parameters: %w", err) + } + b64 := base64.RawStdEncoding + salt, err := b64.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("users: bad salt: %w", err) + } + want, err := b64.DecodeString(parts[5]) + if err != nil || len(want) == 0 { + return false, errors.New("users: bad key") + } + if t < 1 || p < 1 || m < 8*uint32(p) || m > 1<<20 || len(salt) < 8 || len(want) < 16 || len(want) > 64 { + return false, errors.New("users: argon2 parameters out of range") + } + if fmt.Sprintf("m=%d,t=%d,p=%d", m, t, p) != parts[3] { + return false, errors.New("users: malformed argon2 parameters") + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +var ( + dummyOnce sync.Once + dummyHash string +) + +// BurnPasswordCheck runs a full hash comparison against a fixed dummy hash, +// so a login for an unknown address costs as much as one for a real account. +func BurnPasswordCheck(password string) { + dummyOnce.Do(func() { dummyHash, _ = HashPassword("corescope-dummy-password") }) + _, _ = VerifyPassword(dummyHash, password) +} diff --git a/internal/users/password_test.go b/internal/users/password_test.go new file mode 100644 index 00000000..5abb5ade --- /dev/null +++ b/internal/users/password_test.go @@ -0,0 +1,79 @@ +package users + +import ( + "strings" + "testing" +) + +func TestHashPasswordRoundTrip(t *testing.T) { + h, err := HashPassword("correct horse battery") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "$argon2id$v=19$m=19456,t=2,p=1$") { + t.Fatalf("unexpected PHC prefix: %s", h) + } + ok, err := VerifyPassword(h, "correct horse battery") + if err != nil || !ok { + t.Fatalf("verify correct = %v, %v", ok, err) + } + ok, err = VerifyPassword(h, "wrong horse battery") + if err != nil || ok { + t.Fatalf("verify wrong = %v, %v", ok, err) + } + h2, _ := HashPassword("correct horse battery") + if h == h2 { + t.Fatal("two hashes of the same password are identical (salt not random)") + } +} + +func TestVerifyPasswordRejectsForeignFormats(t *testing.T) { + for _, enc := range []string{"", "plain", "$2a$10$abcdefghijklmnopqrstuv", "$argon2i$v=19$m=1,t=1,p=1$c2FsdA$aGFzaA"} { + if _, err := VerifyPassword(enc, "x"); err == nil { + t.Errorf("VerifyPassword(%q) returned no error", enc) + } + } +} + +func TestNewTokenAndHash(t *testing.T) { + raw, hash, err := NewToken() + if err != nil { + t.Fatal(err) + } + if len(raw) != 43 { // 32 bytes, base64url without padding + t.Fatalf("raw token length %d", len(raw)) + } + if HashToken(raw) != hash || len(hash) != 64 { + t.Fatalf("hash mismatch: %s vs %s", HashToken(raw), hash) + } + raw2, _, _ := NewToken() + if raw == raw2 { + t.Fatal("tokens repeat") + } +} + +func TestHashedEmailIsStableAndOpaque(t *testing.T) { + a, b := HashedEmail("alice@example.org"), HashedEmail("alice@example.org") + if a != b || !strings.HasPrefix(a, "sha256:") || strings.Contains(a, "alice") { + t.Fatalf("HashedEmail = %q / %q", a, b) + } +} + +func TestBurnPasswordCheckDoesNotPanic(t *testing.T) { + BurnPasswordCheck("anything at all") +} + +func TestVerifyPasswordRejectsDangerousParams(t *testing.T) { + tail := "$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaA" + for _, enc := range []string{ + "$argon2id$v=19$m=19456,t=0,p=1" + tail, + "$argon2id$v=19$m=19456,t=2,p=0" + tail, + "$argon2id$v=19$m=1073741824,t=2,p=1" + tail, + "$argon2id$v=19$m=19456,t=2,p=1x" + tail, + "$argon2id$v=19$m=4,t=2,p=1" + tail, + } { + if _, err := VerifyPassword(enc, "x"); err == nil { + t.Errorf("VerifyPassword(%q) returned no error", enc) + } + } +} diff --git a/internal/users/schema.go b/internal/users/schema.go new file mode 100644 index 00000000..d16930a4 --- /dev/null +++ b/internal/users/schema.go @@ -0,0 +1,123 @@ +package users + +import ( + "database/sql" + "errors" + "fmt" +) + +// migrations[i] upgrades the schema from version i to i+1. Forward-only: +// never edit a shipped entry, append a new one. +var migrations = [][]string{ + { // v1: sub-project A + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')), + created_at INTEGER NOT NULL, + activated_at INTEGER, + activated_by INTEGER, + last_login_at INTEGER, + email_bouncing INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + user_agent TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX sessions_user ON sessions(user_id)`, + `CREATE TABLE tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('activate','reset','email_change')), + new_email TEXT, + expires_at INTEGER NOT NULL, + used_at INTEGER + )`, + `CREATE INDEX tokens_user ON tokens(user_id, purpose)`, + `CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + actor_user_id INTEGER, + action TEXT NOT NULL, + target_user_id INTEGER, + detail TEXT NOT NULL DEFAULT '{}' + )`, + `CREATE INDEX audit_target ON audit_log(target_user_id)`, + `CREATE INDEX audit_actor ON audit_log(actor_user_id)`, + `CREATE TABLE mail_log ( + id INTEGER PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + to_email TEXT NOT NULL, + purpose TEXT NOT NULL, + provider_message_id TEXT, + sent_at INTEGER NOT NULL, + last_event TEXT NOT NULL DEFAULT 'sent', + last_event_at INTEGER NOT NULL, + last_reason TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX mail_user ON mail_log(user_id)`, + `CREATE UNIQUE INDEX mail_msgid ON mail_log(provider_message_id) WHERE provider_message_id IS NOT NULL`, + `CREATE TABLE mail_events ( + mail_id INTEGER NOT NULL REFERENCES mail_log(id) ON DELETE CASCADE, + event TEXT NOT NULL, + at INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT '', + UNIQUE (mail_id, event, at) + )`, + }, +} + +func (s *Store) migrate() error { + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil { + return fmt.Errorf("users: schema_version: %w", err) + } + v, err := s.SchemaVersion() + if errors.Is(err, sql.ErrNoRows) { + if _, err := s.db.Exec(`INSERT INTO schema_version (version) VALUES (0)`); err != nil { + return fmt.Errorf("users: init schema_version: %w", err) + } + v = 0 + } else if err != nil { + return fmt.Errorf("users: read schema_version: %w", err) + } + if v > len(migrations) { + return fmt.Errorf("users: database schema version %d is newer than this binary supports (%d)", v, len(migrations)) + } + for i := v; i < len(migrations); i++ { + tx, err := s.db.Begin() + if err != nil { + return err + } + for _, stmt := range migrations[i] { + if _, err := tx.Exec(stmt); err != nil { + tx.Rollback() + return fmt.Errorf("users: migration %d: %w", i+1, err) + } + } + if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, i+1); err != nil { + tx.Rollback() + return fmt.Errorf("users: migration %d: %w", i+1, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("users: migration %d: %w", i+1, err) + } + } + return nil +} + +// SchemaVersion returns the applied schema version (sql.ErrNoRows on a +// database that has never been migrated). +func (s *Store) SchemaVersion() (int, error) { + var v int + err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&v) + return v, err +} diff --git a/internal/users/sessions.go b/internal/users/sessions.go new file mode 100644 index 00000000..92fe4f0a --- /dev/null +++ b/internal/users/sessions.go @@ -0,0 +1,127 @@ +package users + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Session is one logged-in device. The raw token exists only in the cookie. +type Session struct { + ID int64 + UserID int64 + CSRFToken string + CreatedAt time.Time + ExpiresAt time.Time + LastSeenAt time.Time + UserAgent string +} + +const maxUserAgent = 200 + +const sessionCols = `id, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent` + +func scanSession(row rowScanner) (*Session, error) { + var s Session + var created, expires, seen int64 + if err := row.Scan(&s.ID, &s.UserID, &s.CSRFToken, &created, &expires, &seen, &s.UserAgent); err != nil { + return nil, err + } + s.CreatedAt, s.ExpiresAt, s.LastSeenAt = fromUnix(created), fromUnix(expires), fromUnix(seen) + return &s, nil +} + +// CreateSession starts a session and returns the raw cookie token. +func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string) (string, *Session, error) { + raw, hash, err := NewToken() + if err != nil { + return "", nil, err + } + csrf, _, err := NewToken() + if err != nil { + return "", nil, err + } + if len(userAgent) > maxUserAgent { + userAgent = strings.ToValidUTF8(userAgent[:maxUserAgent], "") + } + now := s.now() + res, err := s.db.Exec(`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)`, hash, userID, csrf, unix(now), unix(now.Add(ttl)), unix(now), userAgent) + if err != nil { + return "", nil, err + } + id, err := res.LastInsertId() + if err != nil { + return "", nil, err + } + return raw, &Session{ID: id, UserID: userID, CSRFToken: csrf, CreatedAt: fromUnix(unix(now)), + ExpiresAt: fromUnix(unix(now.Add(ttl))), LastSeenAt: fromUnix(unix(now)), UserAgent: userAgent}, nil +} + +// LookupSession resolves a raw cookie token. Unknown and expired sessions +// return ErrNotFound; expired ones are deleted on the way. +func (s *Store) LookupSession(raw string) (*Session, error) { + sess, err := scanSession(s.db.QueryRow(`SELECT `+sessionCols+` FROM sessions WHERE token_hash = ?`, HashToken(raw))) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("users: lookup session: %w", err) + } + if !s.now().Before(sess.ExpiresAt) { + _, _ = s.db.Exec(`DELETE FROM sessions WHERE id = ?`, sess.ID) + return nil, ErrNotFound + } + return sess, nil +} + +// ExtendSession marks the session seen now and moves its expiry to now+ttl. +func (s *Store) ExtendSession(id int64, ttl time.Duration) error { + now := s.now() + return expectOne(s.db.Exec(`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, + unix(now), unix(now.Add(ttl)), id)) +} + +func (s *Store) DeleteSessionByToken(raw string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, HashToken(raw)) + return err +} + +// DeleteSession revokes one session, only if it belongs to userID. +func (s *Store) DeleteSession(userID, sessionID int64) error { + return expectOne(s.db.Exec(`DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID)) +} + +// DeleteUserSessions revokes all of a user's sessions except exceptID (0 = none kept). +func (s *Store) DeleteUserSessions(userID, exceptID int64) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE user_id = ? AND id != ?`, userID, exceptID) + return err +} + +// ListSessions returns a user's sessions, most recently seen first. +func (s *Store) ListSessions(userID int64) ([]Session, error) { + rows, err := s.db.Query(`SELECT `+sessionCols+` FROM sessions WHERE user_id = ? ORDER BY last_seen_at DESC, id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Session + for rows.Next() { + sess, err := scanSession(rows) + if err != nil { + return nil, err + } + out = append(out, *sess) + } + return out, rows.Err() +} + +func (s *Store) PruneExpiredSessions() (int64, error) { + res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, unix(s.now())) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/internal/users/sessions_test.go b/internal/users/sessions_test.go new file mode 100644 index 00000000..4404cea3 --- /dev/null +++ b/internal/users/sessions_test.go @@ -0,0 +1,93 @@ +package users + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestSessionLifecycle(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "s@example.org", "Sess") + raw, sess, err := st.CreateSession(u.ID, time.Hour, strings.Repeat("A", 300)) + if err != nil { + t.Fatal(err) + } + if raw == "" || sess.CSRFToken == "" || len(sess.UserAgent) != 200 { + t.Fatalf("CreateSession: raw=%q sess=%+v", raw, sess) + } + got, err := st.LookupSession(raw) + if err != nil || got.ID != sess.ID || got.UserID != u.ID || got.CSRFToken != sess.CSRFToken { + t.Fatalf("LookupSession = %+v, %v", got, err) + } + if _, err := st.LookupSession("not-a-token"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown token err = %v", err) + } + + clk.Advance(30 * time.Minute) + if err := st.ExtendSession(sess.ID, time.Hour); err != nil { + t.Fatal(err) + } + clk.Advance(45 * time.Minute) // 75 min after creation, 45 after extend + if _, err := st.LookupSession(raw); err != nil { + t.Fatalf("extended session expired early: %v", err) + } + clk.Advance(16 * time.Minute) + if _, err := st.LookupSession(raw); !errors.Is(err, ErrNotFound) { + t.Fatalf("expired session err = %v", err) + } + if list, _ := st.ListSessions(u.ID); len(list) != 0 { + t.Fatalf("expired session not deleted on lookup: %+v", list) + } +} + +func TestDeleteSessions(t *testing.T) { + st, _ := newTestStore(t) + u := mustCreate(t, st, "d@example.org", "Del") + other := mustCreate(t, st, "o@example.org", "Oth") + r1, s1, _ := st.CreateSession(u.ID, time.Hour, "a") + _, s2, _ := st.CreateSession(u.ID, time.Hour, "b") + _, s3, _ := st.CreateSession(u.ID, time.Hour, "c") + _, so, _ := st.CreateSession(other.ID, time.Hour, "x") + + if err := st.DeleteSession(other.ID, s2.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleting someone else's session = %v", err) + } + if err := st.DeleteSession(u.ID, s2.ID); err != nil { + t.Fatal(err) + } + if err := st.DeleteUserSessions(u.ID, s1.ID); err != nil { + t.Fatal(err) + } + list, _ := st.ListSessions(u.ID) + if len(list) != 1 || list[0].ID != s1.ID { + t.Fatalf("after DeleteUserSessions except s1: %+v (s3=%d)", list, s3.ID) + } + if err := st.DeleteSessionByToken(r1); err != nil { + t.Fatal(err) + } + if list, _ := st.ListSessions(u.ID); len(list) != 0 { + t.Fatalf("session survived DeleteSessionByToken: %+v", list) + } + if list, _ := st.ListSessions(other.ID); len(list) != 1 || list[0].ID != so.ID { + t.Fatalf("other user's sessions touched: %+v", list) + } + // Deleting a user cascades to their sessions. + st.Delete(other.ID) + if list, _ := st.ListSessions(other.ID); len(list) != 0 { + t.Fatal("sessions survived user delete") + } +} + +func TestPruneExpiredSessions(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "p@example.org", "Pru") + st.CreateSession(u.ID, time.Hour, "") + st.CreateSession(u.ID, 3*time.Hour, "") + clk.Advance(2 * time.Hour) + n, err := st.PruneExpiredSessions() + if err != nil || n != 1 { + t.Fatalf("PruneExpiredSessions = %d, %v", n, err) + } +} diff --git a/internal/users/store.go b/internal/users/store.go new file mode 100644 index 00000000..1d074031 --- /dev/null +++ b/internal/users/store.go @@ -0,0 +1,127 @@ +// Package users is CoreScope's optional account store (user management, +// docs/specs/2026-10-06-user-management-design.md). It owns users.db, a +// SQLite file separate from the measurement database. cmd/server never +// writes measurement data (#1283); this package is the single, opt-in +// exception, and Open refuses to touch the measurement database by path. +package users + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +var ( + ErrNotFound = errors.New("users: not found") + ErrEmailTaken = errors.New("users: email already registered") + ErrTokenInvalid = errors.New("users: token invalid or already used") + ErrTokenExpired = errors.New("users: token expired") + // ErrAccountChanged: the account is no longer pending with the + // password hash the caller verified (a re-register or an admin got + // there first). + ErrAccountChanged = errors.New("users: account changed since it was read") +) + +// Store is the users.db handle. Safe for concurrent use. +type Store struct { + db *sql.DB + now func() time.Time +} + +// Open opens (creating if needed) the users database at path and applies +// pending migrations. forbidden lists paths Open must refuse; the server +// passes the measurement DB path so a misconfigured dbPath can never turn +// this package into a writer of measurement data. +func Open(path string, forbidden ...string) (*Store, error) { + if strings.ContainsAny(path, "?#") { + return nil, fmt.Errorf("users: database path %q must not contain '?' or '#'", path) + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("users: resolve %s: %w", path, err) + } + for _, f := range forbidden { + if strings.TrimSpace(f) != "" && samePath(abs, f) { + return nil, fmt.Errorf("users: refusing to open %s: it is the measurement database", path) + } + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return nil, fmt.Errorf("users: create dir for %s: %w", path, err) + } + db, err := sql.Open("sqlite", abs+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + return nil, fmt.Errorf("users: open %s: %w", path, err) + } + // Account traffic is tiny; one connection removes SQLITE_BUSY between + // our own writers and keeps the per-connection pragmas in force. + db.SetMaxOpenConns(1) + s := &Store{db: db, now: time.Now} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func samePath(abs, other string) bool { + oa, err := filepath.Abs(other) + if err != nil { + return false + } + a, errA := os.Stat(abs) + b, errB := os.Stat(oa) + if errA == nil && errB == nil { + return os.SameFile(a, b) + } + return strings.EqualFold(filepath.Clean(abs), filepath.Clean(oa)) +} + +// Close closes the database. +func (s *Store) Close() error { return s.db.Close() } + +// SetClock replaces the time source. Tests only. +func (s *Store) SetClock(now func() time.Time) { s.now = now } + +type rowScanner interface{ Scan(dest ...any) error } + +func unix(t time.Time) int64 { return t.Unix() } +func fromUnix(v int64) time.Time { return time.Unix(v, 0).UTC() } + +func fromNullUnix(v sql.NullInt64) *time.Time { + if !v.Valid { + return nil + } + t := fromUnix(v.Int64) + return &t +} + +func nullInt(p *int64) any { + if p == nil { + return nil + } + return *p +} + +func expectOne(res sql.Result, err error) error { + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} diff --git a/internal/users/store_test.go b/internal/users/store_test.go new file mode 100644 index 00000000..0db2c995 --- /dev/null +++ b/internal/users/store_test.go @@ -0,0 +1,72 @@ +package users + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "users.db") + st, err := Open(path) + if err != nil { + t.Fatalf("first Open: %v", err) + } + v, err := st.SchemaVersion() + if err != nil || v != len(migrations) { + t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations)) + } + st.Close() + + st2, err := Open(path) + if err != nil { + t.Fatalf("second Open: %v", err) + } + defer st2.Close() + if v2, _ := st2.SchemaVersion(); v2 != len(migrations) { + t.Fatalf("version after reopen = %d", v2) + } +} + +func TestOpenRefusesForbiddenPath(t *testing.T) { + dir := t.TempDir() + measurement := filepath.Join(dir, "meshcore.db") + if err := os.WriteFile(measurement, []byte{}, 0o644); err != nil { + t.Fatal(err) + } + _, err := Open(measurement, measurement) + if err == nil || !strings.Contains(err.Error(), "measurement database") { + t.Fatalf("Open(measurement) err = %v; want refusal", err) + } + // A relative spelling of the same file is refused too. + wd, _ := os.Getwd() + defer os.Chdir(wd) + os.Chdir(dir) + if _, err := Open("meshcore.db", measurement); err == nil { + t.Fatal("relative path to the measurement DB was not refused") + } +} + +func TestOpenRejectsNewerSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "users.db") + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`UPDATE schema_version SET version = 999`); err != nil { + t.Fatal(err) + } + st.Close() + if _, err := Open(path); err == nil || !strings.Contains(err.Error(), "newer") { + t.Fatalf("Open with newer schema err = %v", err) + } +} + +func TestOpenRejectsDSNCharacters(t *testing.T) { + for _, p := range []string{"users?.db", "users#1.db"} { + if _, err := Open(filepath.Join(t.TempDir(), p)); err == nil { + t.Errorf("Open(%q) accepted", p) + } + } +} diff --git a/internal/users/token.go b/internal/users/token.go new file mode 100644 index 00000000..e363f4cf --- /dev/null +++ b/internal/users/token.go @@ -0,0 +1,32 @@ +package users + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +// NewToken returns a 256-bit random token (base64url, sent to the user) and +// its SHA-256 hex hash (the only form stored). +func NewToken() (raw, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("users: token: %w", err) + } + raw = base64.RawURLEncoding.EncodeToString(b) + return raw, HashToken(raw), nil +} + +// HashToken is the stored form of a raw token. +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// HashedEmail replaces an address in records that outlive the account. +func HashedEmail(email string) string { + sum := sha256.Sum256([]byte(email)) + return "sha256:" + hex.EncodeToString(sum[:8]) +} diff --git a/internal/users/users.go b/internal/users/users.go new file mode 100644 index 00000000..23080fd4 --- /dev/null +++ b/internal/users/users.go @@ -0,0 +1,288 @@ +package users + +import ( + "database/sql" + "errors" + "strings" + "time" +) + +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" +) + +// Valid reports whether r is a known role. +func (r Role) Valid() bool { return r == RoleUser || r == RoleAdmin } + +type Status string + +const ( + StatusPending Status = "pending" + StatusActive Status = "active" + StatusDisabled Status = "disabled" +) + +// User is one account. PasswordHash never leaves the server. +type User struct { + ID int64 + Email string + DisplayName string + PasswordHash string + Role Role + Status Status + CreatedAt time.Time + ActivatedAt *time.Time + ActivatedBy *int64 // admin who activated manually; nil = activated by link + LastLoginAt *time.Time + EmailBouncing bool +} + +const userCols = `id, email, display_name, password_hash, role, status, created_at, activated_at, activated_by, last_login_at, email_bouncing` + +func scanUser(row rowScanner) (*User, error) { + var u User + var role, status string + var created int64 + var activatedAt, activatedBy, lastLogin sql.NullInt64 + var bouncing int + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.PasswordHash, &role, &status, + &created, &activatedAt, &activatedBy, &lastLogin, &bouncing); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + u.Role, u.Status = Role(role), Status(status) + u.CreatedAt = fromUnix(created) + u.ActivatedAt = fromNullUnix(activatedAt) + if activatedBy.Valid { + v := activatedBy.Int64 + u.ActivatedBy = &v + } + u.LastLoginAt = fromNullUnix(lastLogin) + u.EmailBouncing = bouncing != 0 + return &u, nil +} + +// CreatePending inserts a new pending user. email must already be normalized. +func (s *Store) CreatePending(email, displayName, passwordHash string) (*User, error) { + res, err := s.db.Exec(`INSERT INTO users (email, display_name, password_hash, role, status, created_at) + VALUES (?, ?, ?, 'user', 'pending', ?)`, email, displayName, passwordHash, unix(s.now())) + if err != nil { + if isUniqueViolation(err) { + return nil, ErrEmailTaken + } + return nil, err + } + id, err := res.LastInsertId() + if err != nil { + return nil, err + } + return s.GetByID(id) +} + +func (s *Store) GetByID(id int64) (*User, error) { + return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id)) +} + +// GetByEmail looks up a normalized address. +func (s *Store) GetByEmail(email string) (*User, error) { + return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email)) +} + +// Activate moves a pending user to active with role. by is the admin who +// activated manually, nil for link activation. ErrNotFound if not pending. +func (s *Store) Activate(id int64, role Role, by *int64) error { + return expectOne(s.db.Exec(`UPDATE users SET status = 'active', role = ?, activated_at = ?, activated_by = ? + WHERE id = ? AND status = 'pending'`, string(role), unix(s.now()), nullInt(by), id)) +} + +func (s *Store) SetStatus(id int64, st Status) error { + return expectOne(s.db.Exec(`UPDATE users SET status = ? WHERE id = ?`, string(st), id)) +} + +func (s *Store) SetRole(id int64, r Role) error { + return expectOne(s.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, string(r), id)) +} + +func (s *Store) SetPassword(id int64, hash string) error { + return expectOne(s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)) +} + +func (s *Store) SetDisplayName(id int64, name string) error { + return expectOne(s.db.Exec(`UPDATE users SET display_name = ? WHERE id = ?`, name, id)) +} + +// SetEmail changes the address (normalized) and clears the bounce flag. +func (s *Store) SetEmail(id int64, email string) error { + err := expectOne(s.db.Exec(`UPDATE users SET email = ?, email_bouncing = 0 WHERE id = ?`, email, id)) + if isUniqueViolation(err) { + return ErrEmailTaken + } + return err +} + +func (s *Store) SetEmailBouncing(id int64, v bool) error { + b := 0 + if v { + b = 1 + } + return expectOne(s.db.Exec(`UPDATE users SET email_bouncing = ? WHERE id = ?`, b, id)) +} + +func (s *Store) TouchLogin(id int64) error { + return expectOne(s.db.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, unix(s.now()), id)) +} + +// Delete removes a user; sessions and tokens cascade. Mail-log rows survive +// for delivery forensics, with the address replaced by HashedEmail. +func (s *Store) Delete(id int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var exists int + if err := tx.QueryRow(`SELECT 1 FROM users WHERE id = ?`, id).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if err := hashMailLogTx(tx, id); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// ListFilter narrows List. Zero values mean "any". +type ListFilter struct { + Status Status + Role Role + Query string // substring of email or display name, case-insensitive +} + +// List returns at most 1000 users, newest first. SQLite lower()/LIKE fold +// ASCII only, so a non-ASCII search is case-sensitive. +func (s *Store) List(f ListFilter) ([]User, error) { + q := `SELECT ` + userCols + ` FROM users WHERE 1=1` + var args []any + if f.Status != "" { + q += ` AND status = ?` + args = append(args, string(f.Status)) + } + if f.Role != "" { + q += ` AND role = ?` + args = append(args, string(f.Role)) + } + if t := strings.TrimSpace(f.Query); t != "" { + like := "%" + escapeLike(strings.ToLower(t)) + "%" + q += ` AND (email LIKE ? ESCAPE '\' OR lower(display_name) LIKE ? ESCAPE '\')` + args = append(args, like, like) + } + q += ` ORDER BY created_at DESC, id DESC LIMIT 1000` + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, *u) + } + return out, rows.Err() +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +// CountActiveAdmins counts admins whose status is active. +func (s *Store) CountActiveAdmins() (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND status = 'active'`).Scan(&n) + return n, err +} + +// PruneStalePending deletes pending accounts older than maxAge that have no +// unused, unexpired activation token left. +func (s *Store) PruneStalePending(maxAge time.Duration) (int64, error) { + now := unix(s.now()) + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + rows, err := tx.Query(`SELECT id FROM users WHERE status = 'pending' AND created_at < ? + AND NOT EXISTS (SELECT 1 FROM tokens t WHERE t.user_id = users.id AND t.purpose = 'activate' + AND t.used_at IS NULL AND t.expires_at > ?)`, now-int64(maxAge/time.Second), now) + if err != nil { + return 0, err + } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, err + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + for _, id := range ids { + if err := hashMailLogTx(tx, id); err != nil { + return 0, err + } + if _, err := tx.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { + return 0, err + } + } + return int64(len(ids)), tx.Commit() +} + +// hashMailLogTx replaces the plaintext address of every mail_log row of the +// user with HashedEmail of that row's own address (rows may predate an email +// change). Rows already hashed are skipped. The cursor is closed before any +// UPDATE because the store uses a single connection. +func hashMailLogTx(tx *sql.Tx, userID int64) error { + rows, err := tx.Query(`SELECT id, to_email FROM mail_log WHERE user_id = ? AND to_email NOT LIKE 'sha256:%'`, userID) + if err != nil { + return err + } + type row struct { + id int64 + email string + } + var list []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.email); err != nil { + rows.Close() + return err + } + list = append(list, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + for _, r := range list { + if _, err := tx.Exec(`UPDATE mail_log SET to_email = ? WHERE id = ?`, HashedEmail(r.email), r.id); err != nil { + return err + } + } + return nil +} diff --git a/internal/users/users_test.go b/internal/users/users_test.go new file mode 100644 index 00000000..a66d0bc2 --- /dev/null +++ b/internal/users/users_test.go @@ -0,0 +1,127 @@ +package users + +import ( + "errors" + "testing" +) + +func mustCreate(t *testing.T, st *Store, email, name string) *User { + t.Helper() + u, err := st.CreatePending(email, name, "$argon2id$placeholder") + if err != nil { + t.Fatalf("CreatePending(%s): %v", email, err) + } + return u +} + +func TestCreatePendingAndDuplicate(t *testing.T) { + st, clk := newTestStore(t) + u := mustCreate(t, st, "alice@example.org", "Alice") + if u.ID == 0 || u.Status != StatusPending || u.Role != RoleUser || !u.CreatedAt.Equal(clk.Now()) { + t.Fatalf("unexpected user: %+v", u) + } + if _, err := st.CreatePending("alice@example.org", "Other", "x"); !errors.Is(err, ErrEmailTaken) { + t.Fatalf("duplicate err = %v; want ErrEmailTaken", err) + } + got, err := st.GetByEmail("alice@example.org") + if err != nil || got.ID != u.ID { + t.Fatalf("GetByEmail = %+v, %v", got, err) + } + if _, err := st.GetByID(9999); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetByID(missing) err = %v", err) + } +} + +func TestActivateOnlyPending(t *testing.T) { + st, _ := newTestStore(t) + admin := mustCreate(t, st, "admin@example.org", "Admin") + u := mustCreate(t, st, "bob@example.org", "Bob") + by := admin.ID + if err := st.Activate(u.ID, RoleAdmin, &by); err != nil { + t.Fatal(err) + } + got, _ := st.GetByID(u.ID) + if got.Status != StatusActive || got.Role != RoleAdmin || got.ActivatedAt == nil || got.ActivatedBy == nil || *got.ActivatedBy != admin.ID { + t.Fatalf("after Activate: %+v", got) + } + if err := st.Activate(u.ID, RoleUser, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("second Activate err = %v; want ErrNotFound", err) + } +} + +func TestSettersAndEmailUniqueness(t *testing.T) { + st, clk := newTestStore(t) + a := mustCreate(t, st, "a@example.org", "Aa") + mustCreate(t, st, "b@example.org", "Bb") + if err := st.SetEmail(a.ID, "b@example.org"); !errors.Is(err, ErrEmailTaken) { + t.Fatalf("SetEmail to taken = %v", err) + } + if err := st.SetEmail(a.ID, "c@example.org"); err != nil { + t.Fatal(err) + } + if err := st.SetDisplayName(a.ID, "New name"); err != nil { + t.Fatal(err) + } + if err := st.SetPassword(a.ID, "newhash"); err != nil { + t.Fatal(err) + } + if err := st.SetStatus(a.ID, StatusDisabled); err != nil { + t.Fatal(err) + } + if err := st.SetRole(a.ID, RoleAdmin); err != nil { + t.Fatal(err) + } + if err := st.SetEmailBouncing(a.ID, true); err != nil { + t.Fatal(err) + } + if err := st.TouchLogin(a.ID); err != nil { + t.Fatal(err) + } + got, _ := st.GetByID(a.ID) + if got.Email != "c@example.org" || got.DisplayName != "New name" || got.PasswordHash != "newhash" || + got.Status != StatusDisabled || got.Role != RoleAdmin || !got.EmailBouncing || + got.LastLoginAt == nil || !got.LastLoginAt.Equal(clk.Now()) { + t.Fatalf("after setters: %+v", got) + } + if err := st.SetStatus(9999, StatusActive); !errors.Is(err, ErrNotFound) { + t.Fatalf("SetStatus(missing) = %v", err) + } +} + +func TestListFiltersAndAdminCount(t *testing.T) { + st, _ := newTestStore(t) + a := mustCreate(t, st, "alice@example.org", "Alice") + b := mustCreate(t, st, "bob@example.org", "Bob_1") + mustCreate(t, st, "carol@example.org", "Carol") + st.Activate(a.ID, RoleAdmin, nil) + st.Activate(b.ID, RoleUser, nil) + + all, err := st.List(ListFilter{}) + if err != nil || len(all) != 3 { + t.Fatalf("List all = %d, %v", len(all), err) + } + pending, _ := st.List(ListFilter{Status: StatusPending}) + if len(pending) != 1 || pending[0].Email != "carol@example.org" { + t.Fatalf("pending = %+v", pending) + } + admins, _ := st.List(ListFilter{Role: RoleAdmin}) + if len(admins) != 1 || admins[0].ID != a.ID { + t.Fatalf("admins = %+v", admins) + } + q, _ := st.List(ListFilter{Query: "BOB"}) + if len(q) != 1 || q[0].ID != b.ID { + t.Fatalf("query BOB = %+v", q) + } + // LIKE wildcards in the query are literal. + if w, _ := st.List(ListFilter{Query: "_"}); len(w) != 1 || w[0].ID != b.ID { + t.Fatalf("query _ = %+v", w) + } + n, err := st.CountActiveAdmins() + if err != nil || n != 1 { + t.Fatalf("CountActiveAdmins = %d, %v", n, err) + } + st.SetStatus(a.ID, StatusDisabled) + if n, _ := st.CountActiveAdmins(); n != 0 { + t.Fatalf("disabled admin still counted: %d", n) + } +} diff --git a/internal/users/validate.go b/internal/users/validate.go new file mode 100644 index 00000000..431d2aba --- /dev/null +++ b/internal/users/validate.go @@ -0,0 +1,63 @@ +package users + +import ( + "net/mail" + "strings" + "unicode" + "unicode/utf8" +) + +// ValidationError is a user-facing input problem; Msg is safe to show. +type ValidationError struct{ Msg string } + +func (e *ValidationError) Error() string { return e.Msg } + +// NormalizeEmail trims and lowercases an address and checks it is a bare +// addr-spec with a dotted domain. The result is the stored identity. +func NormalizeEmail(raw string) (string, error) { + invalid := &ValidationError{Msg: "enter a valid email address"} + e := strings.ToLower(strings.TrimSpace(raw)) + if e == "" || len(e) > 254 { + return "", invalid + } + addr, err := mail.ParseAddress(e) + if err != nil || addr.Name != "" || addr.Address != e { + return "", invalid + } + at := strings.LastIndexByte(e, '@') + domain := e[at+1:] + if at < 1 || !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return "", invalid + } + return e, nil +} + +// ValidateDisplayName trims a display name and enforces 2–32 characters +// without control, format (bidi, zero-width) or line/paragraph separator +// characters. ZWJ (U+200D) is allowed so emoji sequences survive. +func ValidateDisplayName(raw string) (string, error) { + n := strings.TrimSpace(raw) + if !utf8.ValidString(n) { + return "", &ValidationError{Msg: "display name is not valid text"} + } + if c := utf8.RuneCountInString(n); c < 2 || c > 32 { + return "", &ValidationError{Msg: "display name must be 2 to 32 characters"} + } + for _, r := range n { + if r == '\u200d' { + continue + } + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { + return "", &ValidationError{Msg: "display name contains invisible or control characters"} + } + } + return n, nil +} + +// ValidatePassword enforces length only (10–128 characters), per NIST 800-63B. +func ValidatePassword(p string) error { + if c := utf8.RuneCountInString(p); c < 10 || c > 128 { + return &ValidationError{Msg: "password must be 10 to 128 characters"} + } + return nil +} diff --git a/internal/users/validate_test.go b/internal/users/validate_test.go new file mode 100644 index 00000000..0be15a91 --- /dev/null +++ b/internal/users/validate_test.go @@ -0,0 +1,58 @@ +package users + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalizeEmail(t *testing.T) { + good := map[string]string{ + " Alice@Example.ORG ": "alice@example.org", + "a.b+tag@sub.example.be": "a.b+tag@sub.example.be", + } + for in, want := range good { + got, err := NormalizeEmail(in) + if err != nil || got != want { + t.Errorf("NormalizeEmail(%q) = %q, %v; want %q", in, got, err, want) + } + } + bad := []string{"", "alice", "alice@", "@example.org", "alice@localhost", "Alice ", + "alice@example.org.", strings.Repeat("a", 250) + "@example.org"} + for _, in := range bad { + if _, err := NormalizeEmail(in); err == nil { + t.Errorf("NormalizeEmail(%q) accepted", in) + } else { + var ve *ValidationError + if !errors.As(err, &ve) { + t.Errorf("NormalizeEmail(%q) error is %T, want *ValidationError", in, err) + } + } + } +} + +func TestValidateDisplayName(t *testing.T) { + if got, err := ValidateDisplayName(" Jane Doe "); err != nil || got != "Jane Doe" { + t.Fatalf("got %q, %v", got, err) + } + if _, err := ValidateDisplayName("\U0001F469\u200d\U0001F4BB dev"); err != nil { // ZWJ emoji sequence is allowed + t.Fatalf("ZWJ sequence rejected: %v", err) + } + bad := []string{"a", strings.Repeat("x", 33), "evil\u202eeman", "tab\tname", "zero\u200bwidth", "line\u2028sep"} + for _, in := range bad { + if _, err := ValidateDisplayName(in); err == nil { + t.Errorf("ValidateDisplayName(%q) accepted", in) + } + } +} + +func TestValidatePassword(t *testing.T) { + if err := ValidatePassword("correct horse"); err != nil { + t.Fatalf("valid password rejected: %v", err) + } + for _, in := range []string{"short", strings.Repeat("p", 129)} { + if err := ValidatePassword(in); err == nil { + t.Errorf("ValidatePassword(len %d) accepted", len(in)) + } + } +} diff --git a/public/account.css b/public/account.css new file mode 100644 index 00000000..08a4fad6 --- /dev/null +++ b/public/account.css @@ -0,0 +1,78 @@ +/* Optional user management: header control, account pages, admin table. */ +.nav-account-wrap { position: relative; } +.nav-account-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 48px; min-width: 48px; padding: 6px 12px; + background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--nav-text-muted); font: inherit; font-size: 14px; + text-decoration: none; cursor: pointer; touch-action: manipulation; transition: background .15s, color .15s; } +.nav-account-btn:hover { background: var(--nav-bg2); color: var(--nav-text); } +.nav-account-label { max-width: 12ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); } +.nav-account-menu { position: fixed; right: 0; top: 100%; min-width: 160px; z-index: 1200; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; } +.nav-account-menu[hidden] { display: none; } +.nav-account-menu a, .nav-account-menu button { display: flex; align-items: center; width: 100%; text-align: left; padding: 8px 14px; min-height: 48px; + color: var(--text); background: none; border: 0; font: inherit; text-decoration: none; cursor: pointer; box-sizing: border-box; } +.nav-account-menu a:hover, .nav-account-menu button:hover { background: var(--row-hover); } + +.account-page { display: flex; justify-content: center; padding: var(--space-md); } +.account-card { width: 100%; max-width: 520px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 20px; } +.account-card h2 { margin-top: 0; } +.account-card h3 { margin: 24px 0 8px; font-size: 1rem; } +.account-form { display: flex; flex-direction: column; gap: 12px; } +.account-field { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-sm); color: var(--text-muted); } +.account-field input { padding: 8px 10px; min-height: 48px; border: 1px solid var(--border); border-radius: 6px; + background: var(--input-bg); color: var(--text); font: inherit; box-sizing: border-box; } +/* The global .btn-primary is white on --accent (2.75:1), so account forms use + their own buttons: --text-on-accent on --accent-strong, as .tab-btn.active does. */ +.account-btn { align-self: flex-start; min-height: 48px; padding: 8px 20px; border-radius: 6px; font: inherit; cursor: pointer; touch-action: manipulation; } +.account-btn-primary { background: var(--accent-strong); color: var(--text-on-accent); border: 1px solid var(--accent-strong); } +.account-btn-primary:hover { background: var(--palette-blue-700); } +.account-btn-secondary { background: var(--surface-1); color: var(--text); border: 1px solid var(--border); } +.account-btn-secondary:hover { background: var(--row-hover); } +.account-hint { font-size: var(--fs-sm); color: var(--text-muted); margin: 0; } +.account-msg { margin: 0; min-height: 1.2em; font-size: var(--fs-sm); } +.account-msg.ok { color: var(--status-green-text); } +.account-msg.err { color: var(--status-red-text); } +.account-links { font-size: var(--fs-sm); } +.account-links a { color: var(--link-color); } +.account-mail { text-align: center; padding: 32px 20px; } +.account-card.account-mail .account-mail-icon { display: block; width: 4.5rem; height: 4.5rem; font-size: 4.5rem; margin: 0 auto 12px; color: var(--accent-strong); } +.account-mail-title { margin: 0 0 12px; font-size: var(--fs-2xl); } +.account-mail-title:focus { outline: none; } +.account-mail-intro { font-size: var(--fs-lg); margin: 0 0 12px; } +.account-mail .account-links { font-size: var(--fs-md); } +.account-sessions { list-style: none; padding: 0; margin: 0; } +.account-sessions li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); } + +.cs-auth-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 2000; opacity: 0; pointer-events: none; + background: var(--card-bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; transition: opacity .2s; } +.cs-auth-toast.visible { opacity: 1; } + +.um-page { padding: var(--space-md); } +.um-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.um-filters input, .um-filters select { padding: 6px 8px; min-height: 48px; border: 1px solid var(--border); border-radius: 6px; background: var(--input-bg); color: var(--text); box-sizing: border-box; } +.um-table-wrap { overflow-x: auto; } +.um-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); } +.um-table th, .um-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +.um-table tr:hover td { background: var(--row-hover); } +.um-actions { display: flex; flex-wrap: wrap; gap: 4px; } +.um-actions button { min-height: 48px; padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1); color: var(--text); cursor: pointer; touch-action: manipulation; } +.um-status, .um-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; border: 1px solid var(--border); white-space: nowrap; } +.um-status-active, .um-chip-delivered, .um-chip-opened, .um-chip-clicked { color: var(--status-green-text); } +.um-status-pending, .um-chip-sent, .um-chip-deferred, .um-chip-soft_bounce { color: var(--status-yellow-text); } +.um-status-disabled, .um-chip-hard_bounce, .um-chip-invalid_email, .um-chip-blocked, .um-chip-spam, .um-chip-error, .um-chip-bouncing { color: var(--status-red-text); } +.um-chip-unsubscribed { color: var(--text-muted); } +.um-detail { margin-top: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } +.um-detail ol { padding-left: 18px; } +@media (max-width: 760px) { .um-col-optional { display: none; } } +.account-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 4px; } +a.account-btn { display: inline-flex; align-items: center; text-decoration: none; box-sizing: border-box; } +.um-detail-close { float: right; } + +/* Compact controls on precise pointers; touch keeps the 48px targets above. */ +@media (pointer: fine) { + .nav-account-btn { min-height: 36px; min-width: 36px; padding: 3px 10px; } + .nav-account-menu a, .nav-account-menu button { min-height: 36px; padding: 4px 14px; } + .account-field input { min-height: 36px; padding: 4px 10px; } + .account-btn { min-height: 36px; padding: 4px 16px; } + .um-filters input, .um-filters select { min-height: 36px; padding: 3px 8px; } + .um-actions button { min-height: 36px; padding: 2px 12px; } +} diff --git a/public/account.js b/public/account.js new file mode 100644 index 00000000..3709767e --- /dev/null +++ b/public/account.js @@ -0,0 +1,347 @@ +/* Account pages for optional user management. + * #/account/login | register | activate?token= | forgot | reset?token= | confirm-email?token= | check-mail + * #/account : profile, password, address, sessions, delete + * Every dynamic string goes through escapeHtml; messages use textContent. */ +(function () { + 'use strict'; + + function query() { return new URLSearchParams(location.hash.split('?')[1] || ''); } + // takeToken reads a link token once and strips it from the address bar, so + // it does not linger in history or a bookmark. + function takeToken(view) { + var token = query().get('token') || ''; + if (token) history.replaceState(null, '', '#/account/' + view); + return token; + } + // Unknown names, inherited keys included, show the profile. + function viewName(routeParam) { + var name = routeParam || 'profile'; + return Object.prototype.hasOwnProperty.call(views, name) ? name : 'profile'; + } + // The view the address bar shows (null when not on an account page). + function currentView() { + var parts = location.hash.split('?')[0].split('/'); + return parts[1] === 'account' ? viewName(parts[2]) : null; + } + function val(id) { var el = document.getElementById(id); return el ? el.value : ''; } + + function shell(title, inner) { + return ''; + } + // Inputs carry no name attribute: a native form submission (script not + // attached) then sends no field, so a password can never reach the URL. + function field(id, label, type, autocomplete, extra) { + return ''; + } + function submitBtn(label, secondary) { + return ''; + } + function msgBox(id) { return ''; } + function say(text, ok, id) { + var el = document.getElementById(id || 'accountMsg'); + if (!el) return; + el.textContent = text; + el.classList.toggle('ok', !!ok); + el.classList.toggle('err', !ok); + } + function errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); } + function onSubmit(formId, fn, msgId) { + var f = document.getElementById(formId); + if (!f) return; + f.addEventListener('submit', function (e) { + e.preventDefault(); + var btn = f.querySelector('button[type="submit"]'); + if (btn) btn.disabled = true; + Promise.resolve(fn(f)).catch(function () { say('Network error, try again.', false, msgId); }) + .then(function () { if (btn) btn.disabled = false; }); + }); + } + // What the check-mail view shows. Module-level, never in the URL: a refresh + // finds it empty and shows the generic text. + var mailSent = null; + // location.replace swaps the form's history entry, so Back skips it, and + // fires hashchange for the router. + function goCheckMail(kind, email) { + mailSent = { kind: kind, email: email }; + location.replace('#/account/check-mail'); + } + function checkMailHtml(sent) { + var who = sent ? '' + escapeHtml(sent.email) + '' : ''; + var intro, hint = '', links; + if (!sent) { + intro = 'If the address can be used, we sent you a link. Check your mailbox.'; + links = 'Log in'; + } else if (sent.kind === 'forgot') { + intro = 'If an account exists for ' + who + ', we sent a link to reset your password. It expires in 1 hour.'; + links = 'Back to log in'; + } else { + intro = 'If ' + who + ' can be used, we sent an activation link to it. ' + + 'The link works once and expires in 48 hours. Open it and enter the password you just chose.'; + hint = 'No mail after a few minutes? Check your spam folder, or register again to get a new link.'; + links = 'Log in · Register again'; + } + return ''; + } + function fmtDate(iso) { try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } } + + // An expired or used link answers 410: offer the way to get a new one. + function renewLinkHtml(href, label) { + return ''; + } + function showGone(r, href, label) { + if (r.status !== 410) return; + var box = document.getElementById('accountMsg'); + if (box && !document.getElementById('renewLink')) box.insertAdjacentHTML('afterend', renewLinkHtml(href, label)); + } + + function profileHtml(u) { + return shell('My account', + '' + + '' + msgBox('logoutMsg') + + '

Profile

' + + '

Password

' + + '

Email address

' + + '

Devices

' + msgBox('sessMsg') + + '

Delete account

'); + } + + function sessionsHtml(list) { + var html = ''; + (list || []).forEach(function (s) { + html += '
  • ' + escapeHtml(s.userAgent || 'Unknown device') + '
    last seen ' + escapeHtml(fmtDate(s.lastSeenAt)) + '
    ' + + (s.current ? 'this device' + : '') + '
  • '; + }); + return html; + } + + function tokenView(app, title, view, path, okText, renew) { + var token = takeToken(view); + app.innerHTML = shell(title, msgBox() + ''); + if (!token) { say('This link is incomplete. Open the link from the mail again.', false); return; } + say('Working…', true); + return CSAuth.request('POST', path, { token: token }).then(function (r) { + if (!r.ok) { say(errText(r), false); if (renew) showGone(r, renew.href, renew.label); return r; } + say(okText || (r.data && r.data.message) || 'Done.', true); + return r; + }); + } + + // The profile view on screen, so an auth change can redirect or re-render. + var shown = { app: null, userId: null }; + + var views = { + login: function (app) { + app.innerHTML = shell('Log in', + '' + + ''); + onSubmit('loginForm', function () { + return CSAuth.request('POST', '/api/auth/login', { email: val('loginEmail'), password: val('loginPassword') }).then(function (r) { + if (!r.ok) { say(errText(r), false); return; } + CSAuth.setUser(r.data); + location.hash = '#/account'; + }); + }); + }, + + register: function (app) { + app.innerHTML = shell('Create an account', + '' + + ''); + onSubmit('registerForm', function () { + var email = val('regEmail'); + return CSAuth.request('POST', '/api/auth/register', { + email: email, displayName: val('regName'), password: val('regPassword') + }).then(function (r) { + if (!r.ok) { say(errText(r), false); return; } + goCheckMail('register', email); + }); + }); + }, + + 'check-mail': function (app) { + app.innerHTML = checkMailHtml(mailSent); + var h = document.getElementById('mailSentHeading'); + if (h && h.focus) h.focus(); + }, + + activate: function (app) { + var token = takeToken('activate'); + if (!token) { + app.innerHTML = shell('Activate your account', msgBox() + ''); + say('This link is incomplete. Open the link from the mail again.', false); + return; + } + app.innerHTML = shell('Activate your account', + '' + + ''); + onSubmit('activateForm', function () { + return CSAuth.request('POST', '/api/auth/activate', { token: token, password: val('actPassword') }).then(function (r) { + if (r.status === 401) { say('Wrong password for this account', false); return; } + if (!r.ok) { + say(errText(r), false); + // An already active account needs a login, not a new registration. + if (!/already activated/i.test(errText(r))) showGone(r, '#/account/register', 'Register again to get a new link'); + return; + } + say('Your account is active. You are logged in.', true); + CSAuth.setUser(r.data); + location.hash = '#/account'; + }); + }); + }, + + forgot: function (app) { + app.innerHTML = shell('Forgot password', + ''); + onSubmit('forgotForm', function () { + var email = val('forgotEmail'); + return CSAuth.request('POST', '/api/auth/forgot', { email: email }).then(function (r) { + if (!r.ok) { say(errText(r), false); return; } + goCheckMail('forgot', email); + }); + }); + }, + + reset: function (app) { + var token = takeToken('reset'); + app.innerHTML = shell('Choose a new password', + '' + + ''); + onSubmit('resetForm', function () { + if (val('resetPassword') !== val('resetPassword2')) { say('The passwords do not match.', false); return; } + return CSAuth.request('POST', '/api/auth/reset', { token: token, password: val('resetPassword') }).then(function (r) { + say(r.ok ? r.data.message : errText(r), r.ok); + // The reset ended every session, this browser's included. + if (r.ok) CSAuth.setUser(null); + if (!r.ok) showGone(r, '#/account/forgot', 'Send a new link'); + }); + }); + }, + + 'confirm-email': function (app) { + var p = tokenView(app, 'Confirm your new address', 'confirm-email', '/api/account/confirm-email', null, + { href: '#/account', label: 'Send a new link from your account page' }); + if (p) p.then(function (r) { if (r && r.ok && CSAuth.user()) CSAuth.refreshMe(); }); + }, + + profile: function (app) { + var u = CSAuth.user(); + if (!u) { location.hash = '#/account/login'; return; } + shown.app = app; + shown.userId = u.id; + app.innerHTML = profileHtml(u); + document.getElementById('profName').value = u.displayName; + + document.getElementById('accountPageLogout').addEventListener('click', function () { + return CSAuth.logout('#/account/login').then(function (r) { + if (!r.ok) say(errText(r), false, 'logoutMsg'); + }, function () { say('Network error, try again.', false, 'logoutMsg'); }); + }); + + onSubmit('profileForm', function () { + return CSAuth.request('PATCH', '/api/account', { displayName: val('profName') }).then(function (r) { + if (r.ok) CSAuth.setUser(r.data); + say(r.ok ? 'Saved.' : errText(r), r.ok, 'profMsg'); + }); + }, 'profMsg'); + onSubmit('pwForm', function (form) { + return CSAuth.request('POST', '/api/account/password', { currentPassword: val('pwCurrent'), newPassword: val('pwNew') }).then(function (r) { + if (r.ok) form.reset(); + say(r.ok ? r.data.message : errText(r), r.ok, 'pwMsg'); + if (r.ok) loadSessions(); + }); + }, 'pwMsg'); + onSubmit('emailForm', function (form) { + return CSAuth.request('POST', '/api/account/email', { newEmail: val('emailNew'), currentPassword: val('emailPw') }).then(function (r) { + if (r.ok) form.reset(); + say(r.ok ? r.data.message : errText(r), r.ok, 'emailMsg'); + }); + }, 'emailMsg'); + onSubmit('delForm', function () { + if (!confirm('Delete your account permanently?')) return; + return CSAuth.request('DELETE', '/api/account', { currentPassword: val('delPw') }).then(function (r) { + if (!r.ok) { say(errText(r), false, 'delMsg'); return; } + CSAuth.setUser(null); + CSAuth.notify('Your account was deleted.'); + location.hash = '#/home'; + }); + }, 'delMsg'); + + function loadSessions() { + return CSAuth.request('GET', '/api/account/sessions').then(function (r) { + var list = document.getElementById('sessList'); + if (!list) return; + if (!r.ok) { say(errText(r), false, 'sessMsg'); return; } + list.innerHTML = sessionsHtml(r.data); + }).catch(function () { say('Network error, try again.', false, 'sessMsg'); }); + } + document.getElementById('sessList').addEventListener('click', function (e) { + var id = e.target && e.target.getAttribute && e.target.getAttribute('data-sess'); + if (!id) return; + CSAuth.request('DELETE', '/api/account/sessions/' + encodeURIComponent(id)).then(function (r) { + say(r.ok ? 'Device logged out.' : errText(r), r.ok, 'sessMsg'); + loadSessions(); + }).catch(function () { say('Network error, try again.', false, 'sessMsg'); }); + }); + loadSessions(); + } + }; + + function init(app, routeParam) { + if (!window.CSAuth) { app.innerHTML = shell('Accounts', '

    Accounts are not available.

    '); return; } + app.innerHTML = shell('Accounts', '

    Loading…

    '); + CSAuth.ready().then(function () { + if (!CSAuth.isEnabled()) { app.innerHTML = shell('Accounts', '

    Accounts are not enabled on this instance.

    '); return; } + views[viewName(routeParam)](app); + }); + } + + // Logout (here, in the header or by a 401) leaves the profile for the + // login view; another user logging in re-renders it. A display-name save + // is the same user and keeps the form and its message. + window.addEventListener('cs-auth-changed', function (e) { + if (currentView() !== 'profile' || !shown.app) return; + var u = e.detail; + if (!u) { location.hash = '#/account/login'; return; } + if (u.id !== shown.userId) views.profile(shown.app); + }); + + registerPage('account', { init: init, destroy: function () {} }); + window.CSAccount = { _test: { profileHtml: profileHtml, sessionsHtml: sessionsHtml, renewLinkHtml: renewLinkHtml, views: views, checkMailHtml: checkMailHtml } }; +})(); diff --git a/public/admin-users.js b/public/admin-users.js new file mode 100644 index 00000000..eb4a69ef --- /dev/null +++ b/public/admin-users.js @@ -0,0 +1,255 @@ +/* #/admin/users: admin user management (optional user management). + * Table with filters, per-row actions, and a detail panel with the mail + * delivery timeline and audit log. Every dynamic value goes through escapeHtml. + * Deep link: #/admin/users?status=&role=&q=&id= (read in init, written back + * with replaceState so filter changes add no history entries). */ +(function () { + 'use strict'; + var filters = { status: '', role: '', q: '' }; + var openId = null; + var loadSeq = 0; + // The page on screen and whether it was rendered for an admin, so an auth + // change can redirect or re-render (null app: page left). + var mounted = { app: null, admin: false }; + var STATUSES = ['pending', 'active', 'disabled']; + var ROLES = ['user', 'admin']; + + function errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); } + function fmt(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } } + function say(text, ok) { + var el = document.getElementById('umMsg'); + if (!el) return; + el.textContent = text; + el.classList.toggle('ok', !!ok); + el.classList.toggle('err', !ok); + } + function netErr() { say('Network error, try again.', false); } + function chip(event, reason) { + var e = String(event || 'sent'); + return '' + escapeHtml(e.replace(/_/g, ' ')) + ''; + } + + // Only known filter values and a numeric id are taken from the address bar. + function readHash(hash) { + var p = new URLSearchParams(String(hash || '').split('?')[1] || ''); + var pick = function (v, allowed) { return allowed.indexOf(v) !== -1 ? v : ''; }; + var id = p.get('id') || ''; + return { status: pick(p.get('status'), STATUSES), role: pick(p.get('role'), ROLES), q: p.get('q') || '', id: /^\d+$/.test(id) ? id : '' }; + } + function hashFor(f, id) { + var p = new URLSearchParams(); + if (f.status) p.set('status', f.status); + if (f.role) p.set('role', f.role); + if (f.q) p.set('q', f.q); + if (id) p.set('id', id); + var s = p.toString(); + return '#/admin/users' + (s ? '?' + s : ''); + } + function syncHash() { + var h = hashFor(filters, openId); + if (h !== location.hash) history.replaceState(null, '', h); + } + + // Mirrors the server guards (admin_users_handlers.go): disable only active, + // enable only disabled, role change not for pending, no self/config-admin removal. + function actionsFor(u, me) { + me = me || {}; + var b = function (act, label) { + return ''; + }; + var self = u.id === me.id; + var out = [b('detail', 'Details')]; + if (u.status === 'pending') out.push(b('activate', 'Activate'), b('resend', 'Resend mail')); + if (u.status === 'active' && !self && !u.configAdmin) out.push(b('disable', 'Disable')); + if (u.status === 'disabled') out.push(b('enable', 'Enable')); + if (u.status !== 'pending' && !u.configAdmin) out.push(u.role === 'admin' ? b('demote', 'Make user') : b('promote', 'Make admin')); + if (!self && !u.configAdmin) out.push(b('delete', 'Delete')); + return '
    ' + out.join('') + '
    '; + } + + function rowHtml(u, me) { + var mail = u.lastMail ? chip(u.lastMail.lastEvent, u.lastMail.lastReason) : '—'; + if (u.emailBouncing) mail += ' bouncing'; + return '' + + '' + escapeHtml(u.displayName) + (u.configAdmin ? ' config' : '') + '' + + '' + escapeHtml(u.email) + '' + + '' + escapeHtml(u.role) + '' + + '' + escapeHtml(u.status) + '' + + (u.activatedManually ? ' manual' : '') + '' + + '' + mail + '' + + '' + escapeHtml(fmt(u.createdAt)) + '' + + '' + escapeHtml(fmt(u.lastLoginAt)) + '' + + '' + actionsFor(u, me) + ''; + } + + function detailHtml(d) { + var html = '' + + '

    ' + escapeHtml(d.user.displayName) + ' — ' + escapeHtml(d.user.email) + '

    ' + + '' + + '

    Mail

      '; + (d.mail || []).forEach(function (m) { + html += '
    1. ' + escapeHtml(m.purpose) + ' to ' + escapeHtml(m.to) + ', ' + escapeHtml(fmt(m.sentAt)) + ' ' + chip(m.lastEvent, m.lastReason) + + ' '; + if (m.events && m.events.length) { + html += '
        '; + m.events.forEach(function (e) { + html += '
      • ' + escapeHtml(fmt(e.at)) + ': ' + escapeHtml(e.event) + (e.reason ? ' (' + escapeHtml(e.reason) + ')' : '') + '
      • '; + }); + html += '
      '; + } + html += '
    2. '; + }); + html += '

    Audit

      '; + (d.audit || []).forEach(function (a) { + html += '
    1. ' + escapeHtml(fmt(a.at)) + ': ' + escapeHtml(a.action) + + (a.actorUserId != null ? ' by #' + escapeHtml(String(a.actorUserId)) : '') + '
    2. '; + }); + return html + '
    '; + } + + function load() { + var q = new URLSearchParams(); + if (filters.status) q.set('status', filters.status); + if (filters.role) q.set('role', filters.role); + if (filters.q) q.set('q', filters.q); + var seq = ++loadSeq; + return CSAuth.request('GET', '/api/admin/users?' + q.toString()).then(function (r) { + var body = document.getElementById('umBody'); + if (!body || seq !== loadSeq) return; + if (!r.ok) { say(errText(r), false); return; } + var me = CSAuth.user(); + var html = ''; + // The server caps the list at 1000 rows, so a full tbody rebuild is bounded. + r.data.forEach(function (u) { html += rowHtml(u, me); }); + body.innerHTML = html || 'No users match.'; + if (openId) showDetail(openId); + }).catch(netErr); + } + + function closeDetail() { + openId = null; + var el = document.getElementById('umDetail'); + if (el) el.hidden = true; + syncHash(); + } + + // focus: opened by the admin (not a deep link or a list reload), so move + // keyboard focus to the panel heading. + function showDetail(id, focus) { + openId = id; + syncHash(); + return CSAuth.request('GET', '/api/admin/users/' + encodeURIComponent(id)).then(function (r) { + var el = document.getElementById('umDetail'); + if (!el || openId !== id) return; + if (!r.ok) { say(errText(r), false); closeDetail(); return; } + el.innerHTML = detailHtml(r.data); + el.hidden = false; + if (focus) { + var h = el.querySelector('h3'); + if (h) h.focus(); + } + }).catch(netErr); + } + + var confirmText = { + activate: 'Activate this account without mail confirmation? The address will stay unverified.', + disable: 'Disable this account? The user is logged out everywhere.', + delete: 'Delete this account permanently?', + demote: 'Remove admin rights from this user?' + }; + var endpoints = { + activate: ['POST', '/activate'], resend: ['POST', '/resend-activation'], disable: ['POST', '/disable'], + enable: ['POST', '/enable'], delete: ['DELETE', ''], promote: ['POST', '/role', { role: 'admin' }], + demote: ['POST', '/role', { role: 'user' }] + }; + + function onAction(e) { + var btn = e.target.closest('button[data-act]'); + if (!btn) return; + var act = btn.getAttribute('data-act'); + var id = btn.getAttribute('data-id'); + if (act === 'detail') { showDetail(id, true); return; } + if (act === 'close') { + var was = openId; + closeDetail(); + // ids are numeric (server ids, readHash), safe in the selector. + var opener = was && document.querySelector('button[data-act="detail"][data-id="' + was + '"]'); + if (opener) opener.focus(); + return; + } + if (act === 'refresh') { + CSAuth.request('POST', '/api/admin/users/' + encodeURIComponent(id) + '/mail/' + encodeURIComponent(btn.getAttribute('data-mail')) + '/refresh') + .then(function (r) { say(r.ok ? 'Mail status refreshed.' : errText(r), r.ok); load(); }).catch(netErr); + return; + } + if (confirmText[act] && !confirm(confirmText[act])) return; + var ep = endpoints[act]; + CSAuth.request(ep[0], '/api/admin/users/' + encodeURIComponent(id) + ep[1], ep[2]).then(function (r) { + say(r.ok ? 'Done.' : errText(r), r.ok); + if (act === 'delete' && r.ok && openId === id) closeDetail(); + var me = CSAuth.user(); + if (r.ok && (act === 'promote' || act === 'demote') && me && String(me.id) === id) { + // Own role changed: refresh so admin-only UI disappears. + return CSAuth.refreshMe().then(function () { + if (!CSAuth.isAdmin()) location.hash = '#/account'; + else load(); + }); + } + return load(); + }).catch(netErr); + } + + function init(app, routeParam) { + app.innerHTML = '

    Loading…

    '; + var ready = window.CSAuth ? CSAuth.ready() : Promise.resolve(); + ready.then(function () { + if (routeParam !== 'users' || !window.CSAuth || !CSAuth.isEnabled()) { + app.innerHTML = '

    Not found

    '; + return; + } + mounted.app = app; + mounted.admin = CSAuth.isAdmin(); + if (!mounted.admin) { + app.innerHTML = '

    Users

    Admins only. Log in

    '; + return; + } + var h = readHash(location.hash); + filters = { status: h.status, role: h.role, q: h.q }; + openId = h.id || null; + app.innerHTML = '

    Users

    ' + + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    ' + + '' + + '' + + '
    NameEmailRoleStatusLast mailCreatedLast loginActions
    ' + + '
    '; + document.getElementById('umQ').value = filters.q; + document.getElementById('umStatus').value = filters.status; + document.getElementById('umRole').value = filters.role; + var deb = debounce(function () { filters.q = document.getElementById('umQ').value.trim(); syncHash(); load(); }, 250); + document.getElementById('umQ').addEventListener('input', deb); + document.getElementById('umStatus').addEventListener('change', function (e) { filters.status = e.target.value; syncHash(); load(); }); + document.getElementById('umRole').addEventListener('change', function (e) { filters.role = e.target.value; syncHash(); load(); }); + app.querySelector('.um-page').addEventListener('click', onAction); + load(); + }).catch(function () { + app.innerHTML = '

    Users

    Network error, reload the page.

    '; + }); + } + + // Logout (header, account page or a 401) goes to the login view; a login + // or role change that flips admin access re-renders the page. + window.addEventListener('cs-auth-changed', function (e) { + // A logout that already moved elsewhere (the header goes home) wins. + if (!mounted.app || location.hash.split('?')[0] !== '#/admin/users') return; + if (!e.detail) { location.hash = '#/account/login'; return; } + if (CSAuth.isAdmin() !== mounted.admin) init(mounted.app, 'users'); + }); + + registerPage('admin', { init: init, destroy: function () { openId = null; loadSeq++; mounted.app = null; } }); + window.CSAdminUsers = { _test: { actionsFor: actionsFor, rowHtml: rowHtml, detailHtml: detailHtml, readHash: readHash, hashFor: hashFor } }; +})(); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 00000000..d38638b4 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,165 @@ +/* Optional user management client (docs/specs/2026-10-06-user-management-design.md). + * Inert unless /api/config/client advertises userManagement.enabled (roles.js + * sets window.MC_USER_MGMT). Exposes window.CSAuth and window.CS_USER, and + * fires 'cs-auth-changed' on window whenever the user changes. */ +(function () { + 'use strict'; + var state = { enabled: false, user: null, ready: null }; + + function request(method, path, body) { + var opts = { method: method, credentials: 'same-origin', headers: { 'Accept': 'application/json' } }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + if (method !== 'GET' && state.user && state.user.csrfToken) { + opts.headers['X-CS-CSRF'] = state.user.csrfToken; + } + return fetch(path, opts).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (res.status === 401 && state.user && path !== '/api/auth/login' && path !== '/api/auth/activate') { + setUser(null); + notify('You were logged out.'); + } + return { ok: res.ok, status: res.status, data: data || {} }; + }); + }); + } + + function notify(msg) { + var el = document.getElementById('csAuthToast'); + if (!el) { + el = document.createElement('div'); + el.id = 'csAuthToast'; + el.className = 'cs-auth-toast'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + document.body.appendChild(el); + } + el.textContent = msg; + el.classList.add('visible'); + clearTimeout(notify._t); + notify._t = setTimeout(function () { el.classList.remove('visible'); }, 4000); + } + + function setUser(u) { + state.user = u || null; + window.CS_USER = state.user; + renderControl(); + window.dispatchEvent(new CustomEvent('cs-auth-changed', { detail: state.user })); + } + + // logout ends the session on the server. Only when that succeeded does it + // move to next and then clear the user, so pages listening for + // 'cs-auth-changed' already see the new view. A refusal keeps the user + // and is returned for the caller to show. + function logout(next) { + return request('POST', '/api/auth/logout').then(function (r) { + if (r.ok) { + location.hash = next; + setUser(null); + } + return r; + }); + } + + function refreshMe() { + return request('GET', '/api/auth/me').then(function (r) { + setUser(r.ok ? r.data : null); + return state.user; + }); + } + + function icon(id) { + return ''; + } + + function closeMenu() { + var m = document.getElementById('accountMenu'); + var b = document.getElementById('accountToggle'); + if (m && !m.hidden) { m.hidden = true; if (b) b.setAttribute('aria-expanded', 'false'); } + } + + // The menu is position:fixed (.top-nav clips overflow); place it under the toggle, like the More menu (#1406). + function positionMenu() { + var m = document.getElementById('accountMenu'); + var b = document.getElementById('accountToggle'); + if (!m || !b) return; + var r = b.getBoundingClientRect(); + m.style.top = (r.bottom + 4) + 'px'; + m.style.right = (window.innerWidth - r.right) + 'px'; + m.style.left = 'auto'; + } + + function renderControl() { + if (!state.enabled) return; + var right = document.querySelector('.top-nav .nav-right'); + if (!right) return; + var wrap = document.getElementById('accountWrap'); + if (!wrap) { + wrap = document.createElement('div'); + wrap.id = 'accountWrap'; + wrap.className = 'nav-account-wrap'; + right.insertBefore(wrap, document.getElementById('hamburger')); + } + var u = state.user; + if (!u) { + wrap.innerHTML = ''; + return; + } + var html = '' + + ''; + wrap.innerHTML = html; + var btn = document.getElementById('accountToggle'); + var menu = document.getElementById('accountMenu'); + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var open = menu.hidden; + if (open) positionMenu(); + menu.hidden = !open; + btn.setAttribute('aria-expanded', String(open)); + }); + menu.addEventListener('click', closeMenu); + document.getElementById('accountLogout').addEventListener('click', function () { + logout('#/home').then(function (r) { + if (!r.ok) notify((r.data && r.data.error) || ('Logout failed (HTTP ' + r.status + ')')); + }, function () { notify('Network error, try again.'); }); + }); + } + + document.addEventListener('click', closeMenu); + window.addEventListener('resize', function () { + var m = document.getElementById('accountMenu'); + if (m && !m.hidden) positionMenu(); + }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); }); + + state.ready = Promise.resolve(window.MeshConfigReady).then(function () { + if (!window.MC_USER_MGMT || !window.MC_USER_MGMT.enabled) return null; + state.enabled = true; + renderControl(); + return refreshMe(); + }).catch(function () { return null; }); + + window.CSAuth = { + request: request, + refreshMe: refreshMe, + logout: logout, + setUser: setUser, + notify: notify, + ready: function () { return state.ready; }, + isEnabled: function () { return state.enabled; }, + user: function () { return state.user; }, + isAdmin: function () { return !!(state.user && state.user.role === 'admin'); }, + adminHeaders: function () { + return (state.user && state.user.role === 'admin') ? { 'X-CS-CSRF': state.user.csrfToken } : {}; + }, + // Unit-test hook (tests/unit/test-user-management-ui.js). + _test: { renderControl: renderControl, state: state } + }; +})(); diff --git a/public/bottom-nav.js b/public/bottom-nav.js index ac1d4b0b..abcfb3b4 100644 --- a/public/bottom-nav.js +++ b/public/bottom-nav.js @@ -88,14 +88,35 @@ // by which point MeshConfigReady has resolved window.MC_CLIENT_RX_COVERAGE. var COVERAGE_ROUTE = { route: 'rx-coverage', hash: '#/rx-coverage', label: 'Coverage', ph: 'broadcast' }; + // Optional user management (config `userManagement.enabled`). The top-nav + // account control is hidden at <=768px, so the entry lives here. The label + // follows the login state; see the cs-auth-changed listener below. + // !! Keep in sync with public/nav-drawer.js accountRoute(). + function accountRoute() { + return window.CS_USER + ? { route: 'account', hash: '#/account', label: 'My account', ph: 'user-circle' } + : { route: 'account', hash: '#/account/login', label: 'Log in', ph: 'user-circle' }; + } + function moreRoutes() { - if (!window.MC_CLIENT_RX_COVERAGE) return MORE_ROUTES; var out = MORE_ROUTES.slice(); - var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; - out.splice(after, 0, COVERAGE_ROUTE); + if (window.MC_CLIENT_RX_COVERAGE) { + var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; + out.splice(after, 0, COVERAGE_ROUTE); + } + if (window.MC_USER_MGMT) out.push(accountRoute()); return out; } + window.addEventListener('cs-auth-changed', function () { + var a = document.querySelector('#' + SHEET_ID + ' [data-bottom-nav-more-route="account"]'); + if (!a) return; + var r = accountRoute(); + a.setAttribute('href', escapeHtml(r.hash)); + var lb = a.querySelector('.bottom-nav-sheet-label'); + if (lb) lb.textContent = r.label; + }); + var SHEET_ID = 'bottomNavMoreSheet'; function currentRoute() { @@ -218,7 +239,7 @@ moreRoutes().forEach(function (r) { var a = document.createElement('a'); a.className = 'bottom-nav-sheet-item'; - a.setAttribute('href', r.hash); + a.setAttribute('href', escapeHtml(r.hash)); a.setAttribute('role', 'menuitem'); a.setAttribute('data-bottom-nav-more-route', r.route); a.setAttribute('data-route', r.route); diff --git a/public/customize-v2.js b/public/customize-v2.js index 68fb33e6..e615ef75 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -48,6 +48,7 @@ navActiveBg: '--nav-active-bg', background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border', statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red', + statusYellowText: '--status-yellow-text', statusRedText: '--status-red-text', surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3', sectionBg: '--section-bg', cardBg: '--card-bg', contentBg: '--content-bg', detailBg: '--detail-bg', @@ -1077,6 +1078,9 @@ var _gfPolygon = null; var _gfClosingLine = null; var _gfLoaded = false; // true after initial server load + var _gfServer = null; // last /config/geo-filter answer (writeEnabled) + var _gfContainer = null; // panel of the open geofilter tab, for auth changes + var _gfAuthListening = false; function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } function escAttr(s) { return (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/Buffer km:' + '' + '' + - '
    ' + + '
    ' + '' + '
    ' + '
    ' + @@ -1944,33 +1948,84 @@ el.style.color = ok ? 'var(--status-green)' : 'var(--status-red)'; } + // Optional user management: a logged-in admin's session (cookie + CSRF + // header) replaces the API key. Returns null when neither is available. + function _gfAuthHeaders(container, base, csAuth) { + var h = Object.assign({}, base || {}); + if (csAuth && csAuth.isAdmin()) return Object.assign(h, csAuth.adminHeaders()); + var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; + if (!apiKey) return null; + h['X-API-Key'] = apiKey; + return h; + } + + // The editor is usable with a write-capable API key (gf.writeEnabled) or an + // admin session (server: requireAdmin accepts both). + function _gfCanEdit(gf, csAuth) { + return !!((gf && gf.writeEnabled) || (csAuth && csAuth.isAdmin())); + } + + // Shows or hides the edit controls, the prune section (needs a polygon) + // and the API-key field (not needed with an admin session) for the + // current auth state. Runs after the server answer, after CSAuth.ready() + // and on every 'cs-auth-changed', so a later login or logout is applied. + function _gfApplyAuth(container, gf, csAuth, pointCount) { + var canEdit = _gfCanEdit(gf, csAuth); + _gfWriteEnabled = canEdit; + var editEl = container.querySelector('#cv2-gf-edit'); + if (editEl) editEl.style.display = canEdit ? '' : 'none'; + var pruneEl = container.querySelector('#cv2-gf-prune-section'); + if (pruneEl) pruneEl.style.display = canEdit && pointCount >= 3 ? '' : 'none'; + var keyField = container.querySelector('#cv2-gf-apikey-field'); + if (keyField) keyField.hidden = !!(csAuth && csAuth.isAdmin()); + return canEdit; + } + + // Re-applies access on a cached re-render or an auth change. Only with + // user management on: with it off the tab behaves exactly as before (the + // controls are decided once, by the first server answer). The prune + // section follows the saved server polygon, not unsaved drawing. + function _gfReapply(container, gf, csAuth) { + if (!(csAuth && csAuth.isEnabled())) return false; + var saved = gf && gf.polygon ? gf.polygon.length : 0; + _gfApplyAuth(container, gf, csAuth, saved); + return true; + } + + function _gfReapplyAuth() { + // Only once the server answered: after a failed load nothing is editable. + if (_gfContainer && _gfServer) _gfReapply(_gfContainer, _gfServer, window.CSAuth); + } + function _gfSave(container) { if (_gfPoints.length < 3) { _gfMsg(container, 'Need at least 3 polygon points.', false); return; } - var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; - if (!apiKey) { _gfMsg(container, 'API key required to save.', false); return; } + var headers = _gfAuthHeaders(container, { 'Content-Type': 'application/json' }, window.CSAuth); + if (!headers) { _gfMsg(container, 'API key required to save.', false); return; } var bufferKm = parseFloat((container.querySelector('#cv2-gf-buffer') || {}).value) || 0; fetch('/api/config/geo-filter', { method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + headers: headers, body: JSON.stringify({ polygon: _gfPoints, bufferKm: bufferKm }) }).then(function (r) { if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); + if (_gfServer) _gfServer = Object.assign({}, _gfServer, { polygon: _gfPoints.slice() }); _gfMsg(container, 'Saved. Filter is active immediately.', true); _gfStatus(container, _gfPoints.length + ' points · bufferKm=' + bufferKm + ' · saved'); }).catch(function (e) { _gfMsg(container, 'Error: ' + e.message, false); }); } function _gfRemove(container) { - var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; - if (!apiKey) { _gfMsg(container, 'API key required.', false); return; } + var headers = _gfAuthHeaders(container, { 'Content-Type': 'application/json' }, window.CSAuth); + if (!headers) { _gfMsg(container, 'API key required.', false); return; } if (!confirm('Remove geo filter? All nodes will be allowed through.')) return; fetch('/api/config/geo-filter', { method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + headers: headers, body: JSON.stringify({ polygon: null }) }).then(function (r) { if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); _gfPoints = []; _gfLoaded = true; + if (_gfServer) _gfServer = Object.assign({}, _gfServer, { polygon: null }); _gfRender(); _gfStatus(container, 'No geo filter. Click the map to draw a polygon.'); _gfMsg(container, 'Geo filter removed.', true); @@ -1988,13 +2043,13 @@ } function _gfPrunePreview(container) { - var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; - if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; } + var headers = _gfAuthHeaders(container, {}, window.CSAuth); + if (!headers) { _gfPruneMsg(container, 'API key required.', false); return; } var btn = container.querySelector('#cv2-gf-prune-preview'); if (btn) btn.textContent = 'Loading…'; fetch('/api/admin/prune-geo-filter', { method: 'POST', - headers: { 'X-API-Key': apiKey } + headers: headers }).then(function (r) { if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); return r.json(); @@ -2026,14 +2081,14 @@ function _gfPruneConfirm(container) { if (!_gfPruneNodes.length) { _gfPruneMsg(container, 'Run preview first.', false); return; } - var apiKey = (container.querySelector('#cv2-gf-apikey') || {}).value || ''; - if (!apiKey) { _gfPruneMsg(container, 'API key required.', false); return; } + var headers = _gfAuthHeaders(container, { 'Content-Type': 'application/json' }, window.CSAuth); + if (!headers) { _gfPruneMsg(container, 'API key required.', false); return; } var count = _gfPruneNodes.length; if (!confirm('Delete ' + count + ' node' + (count !== 1 ? 's' : '') + ' from the database? This cannot be undone.')) return; var pubkeys = _gfPruneNodes.map(function (n) { return n.pubkey; }); fetch('/api/admin/prune-geo-filter?confirm=true', { method: 'POST', - headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + headers: headers, body: JSON.stringify({ pubkeys: pubkeys }) }).then(function (r) { if (!r.ok) return r.json().then(function (e) { throw new Error(e.error || ('HTTP ' + r.status)); }); @@ -2060,27 +2115,22 @@ if (!_gfLoaded) { api('/config/geo-filter', { ttl: 0 }).then(function (gf) { - // Show edit controls only on servers that have a write-capable API key configured - if (gf && gf.writeEnabled) { - _gfWriteEnabled = true; - var editEl = container.querySelector('#cv2-gf-edit'); - if (editEl) editEl.style.display = ''; - } + _gfServer = gf; if (gf && gf.polygon && gf.polygon.length >= 3) { _gfPoints = gf.polygon.map(function (p) { return [p[0], p[1]]; }); + } else { + _gfPoints = []; + } + // Edit controls need a write-capable API key on the server or an admin session. + var canEdit = _gfApplyAuth(container, gf, window.CSAuth, _gfPoints.length); + if (_gfPoints.length >= 3) { var buf = container.querySelector('#cv2-gf-buffer'); if (buf) buf.value = gf.bufferKm || 0; _gfRender(); if (_gfPolygon) _gfMap.fitBounds(_gfPolygon.getBounds(), { padding: [20, 20] }); _gfStatus(container, gf.polygon.length + ' points · bufferKm=' + (gf.bufferKm || 0)); - // Show prune section when a polygon is active and write access is available - if (gf.writeEnabled) { - var pruneEl = container.querySelector('#cv2-gf-prune-section'); - if (pruneEl) pruneEl.style.display = ''; - } } else { - _gfPoints = []; - _gfStatus(container, gf && gf.writeEnabled ? 'No geo filter. Click the map to open the editor.' : 'No geo filter configured.'); + _gfStatus(container, canEdit ? 'No geo filter. Click the map to open the editor.' : 'No geo filter configured.'); _gfMap.setView([50.5, 4.4], 5); } _gfLoaded = true; @@ -2101,9 +2151,19 @@ _gfStatus(container, _gfPoints.length ? _gfPoints.length + ' points (need at least 3).' : 'Click the map to draw a polygon.'); _gfRender(); } + if (_gfServer) _gfReapply(container, _gfServer, window.CSAuth); setTimeout(function () { if (_gfMap) _gfMap.invalidateSize(); }, 100); } + // The tab can open before /api/auth/me answers, and a login or logout + // can follow without a reload: re-evaluate the editor on both. + _gfContainer = container; + if (window.CSAuth) CSAuth.ready().then(_gfReapplyAuth); + if (!_gfAuthListening) { + _gfAuthListening = true; + window.addEventListener('cs-auth-changed', _gfReapplyAuth); + } + _gfMap.on('click', function () { _gfOpenModal(container); }); container.querySelector('#cv2-gf-save').addEventListener('click', function () { _gfSave(container); }); @@ -2919,6 +2979,10 @@ isValidColor: isValidColor, isValidHomeUrl: isValidHomeUrl, isOverridden: _isOverridden, + _gfCanEdit: _gfCanEdit, + _gfAuthHeaders: _gfAuthHeaders, + _gfApplyAuth: _gfApplyAuth, + _gfReapply: _gfReapply, // #1496 — full reset (not just STORAGE_KEY). See _resetAll() above. resetAll: _resetAll, // Exposed for tests — see test-issue-1509-detect-preset.js. diff --git a/public/icons/phosphor-sprite.svg b/public/icons/phosphor-sprite.svg index 549ea937..b612c2bf 100644 --- a/public/icons/phosphor-sprite.svg +++ b/public/icons/phosphor-sprite.svg @@ -94,6 +94,7 @@ + diff --git a/public/index.html b/public/index.html index 715c74c4..b70cdae7 100644 --- a/public/index.html +++ b/public/index.html @@ -47,6 +47,7 @@ + + @@ -233,6 +235,8 @@ + + diff --git a/public/nav-drawer.js b/public/nav-drawer.js index 20fa0f44..5fe5796b 100644 --- a/public/nav-drawer.js +++ b/public/nav-drawer.js @@ -73,14 +73,34 @@ // point MeshConfigReady has resolved window.MC_CLIENT_RX_COVERAGE. var COVERAGE_ROUTE = { route: 'rx-coverage', hash: '#/rx-coverage', label: 'Coverage', ph: 'broadcast' }; + // Optional user management (config `userManagement.enabled`): the top-nav + // account control is hidden on touch layouts, so the entry lives here too. + // !! Keep in sync with public/bottom-nav.js accountRoute(). + function accountRoute() { + return window.CS_USER + ? { route: 'account', hash: '#/account', label: 'My account', ph: 'user-circle' } + : { route: 'account', hash: '#/account/login', label: 'Log in', ph: 'user-circle' }; + } + function routes() { - if (!window.MC_CLIENT_RX_COVERAGE) return ROUTES; var out = ROUTES.slice(); - var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; - out.splice(after, 0, COVERAGE_ROUTE); + if (window.MC_CLIENT_RX_COVERAGE) { + var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; + out.splice(after, 0, COVERAGE_ROUTE); + } + if (window.MC_USER_MGMT) out.push(accountRoute()); return out; } + window.addEventListener('cs-auth-changed', function () { + var a = document.querySelector('[data-nav-drawer-item="account"]'); + if (!a) return; + var r = accountRoute(); + a.setAttribute('href', escapeHtml(r.hash)); + var lb = a.querySelector('.nav-drawer-label'); + if (lb) lb.textContent = r.label; + }); + function phIconHTML(name) { return ''; @@ -167,7 +187,7 @@ routes().forEach(function (r) { var a = document.createElement('a'); a.className = 'nav-drawer-item'; - a.setAttribute('href', r.hash); + a.setAttribute('href', escapeHtml(r.hash)); a.setAttribute('data-nav-drawer-item', r.route); a.setAttribute('data-route', r.route); diff --git a/public/perf.js b/public/perf.js index 990ee1bd..1ec00dd5 100644 --- a/public/perf.js +++ b/public/perf.js @@ -72,6 +72,25 @@ if (typeof window !== 'undefined') { window.detectPerfAnomalies = detectPerfAnomalies; } +// resetPerfStats: the "Reset Stats" button's server call. Resolves true +// when the local counters may be cleared. With user management off it is the +// original fire-and-forget POST (nothing visible changes); with it on, an +// admin session authorises the call and a refusal is reported. +async function resetPerfStats(win, fetchFn, alertFn) { + if (!win.MC_USER_MGMT) { + await fetchFn('/api/perf/reset', { method: 'POST' }); + return true; + } + try { + var r = await fetchFn('/api/perf/reset', { method: 'POST', headers: win.CSAuth ? win.CSAuth.adminHeaders() : {} }); + if (!r.ok) throw new Error('HTTP ' + r.status); + } catch (e) { + alertFn('Reset failed: ' + e.message); + return false; + } + return true; +} + function renderVersionCard(health) { if (!health || (!health.version && !health.commit)) return ''; var ver = health.version && health.version !== 'unknown' ? health.version : null; @@ -344,7 +363,7 @@ function renderVersionCard(health) { el.innerHTML = html; document.getElementById('perfReset')?.addEventListener('click', async () => { - await fetch('/api/perf/reset', { method: 'POST' }); + if (!(await resetPerfStats(window, fetch, alert))) return; if (window._apiPerf) { window._apiPerf = { calls: 0, totalMs: 0, log: [] }; } refresh(); }); diff --git a/public/roles.js b/public/roles.js index 3c297e31..1c469e04 100644 --- a/public/roles.js +++ b/public/roles.js @@ -655,6 +655,8 @@ window.MC_CUSTOMIZER_CFG = (cfg.customizer && typeof cfg.customizer === 'object') ? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] } : { disabledTabs: [] }; + // Optional user management: present only when the server enables it. + window.MC_USER_MGMT = (cfg.userManagement && cfg.userManagement.enabled) ? { enabled: true } : null; // #1574 — operator-configurable cap on /live map node count. if (cfg.liveMapMaxNodes != null) window.LIVE_MAP_MAX_NODES = cfg.liveMapMaxNodes; // #1784 — path trust threshold: minimum hash bytes for mapping evidence. diff --git a/public/style.css b/public/style.css index 4f34e18e..b0597c04 100644 --- a/public/style.css +++ b/public/style.css @@ -262,6 +262,11 @@ step darker clears it with margin (6.23:1 on white, 5.74:1 on --surface-0) without thinning the tint that makes the chip legible. */ --sa-chip-observed-fg: var(--palette-green-800, #166534); + /* Text-only yellow/red for surfaces that print status words on light cards + (user-management chips): yellow-800 #854d0e is 6.85:1 on #fff and 6.13:1 on the row-hover tint #eef2ff (yellow-700 #a16207 was 4.4:1 there); red-700 #b91c1c + is 6.47:1 on #fff. Dark theme keeps the bright hues, like green-text. */ + --status-yellow-text: #854d0e; + --status-red-text: var(--palette-red-700, #b91c1c); --status-yellow: #eab308; --status-red: #ef4444; --status-orange: #f97316; @@ -396,6 +401,8 @@ --sa-chip-observed-fg: #22c55e; /* #1719 — keep --skew-badge-no-clock-bg dark enough for #fff text. */ --skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563); + --status-yellow-text: #eab308; + --status-red-text: var(--palette-red-400, #f87171); --status-yellow: #eab308; --status-red: #ef4444; --status-orange: #f97316; @@ -462,6 +469,8 @@ /* #1996: dark already passes at 6.48:1 — keep the bright hue. */ --sa-chip-observed-fg: #22c55e; --skew-badge-no-clock-bg: var(--palette-gray-600, #4b5563); + --status-yellow-text: #eab308; + --status-red-text: var(--palette-red-400, #f87171); --status-yellow: #eab308; --status-red: #ef4444; --status-orange: #f97316; diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 079c3ca6..a5fbb97a 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -114,7 +114,8 @@ "tests/e2e/test-slideover-1168-munger-e2e.js", "tests/e2e/test-table-fluid-e2e.js", "tests/e2e/test-touch-gestures-coverage-e2e.js", - "tests/e2e/test-touch-targets.js" + "tests/e2e/test-touch-targets.js", + "tests/e2e/test-user-management-e2e.js" ] }, "domIntegration": { diff --git a/test-all.sh b/test-all.sh index b0495e8e..79a946a8 100755 --- a/test-all.sh +++ b/test-all.sh @@ -189,6 +189,7 @@ node tests/unit/test-slideover-1056-rowsel-strict.js node tests/unit/test-top-routes-overlay.js node tests/unit/test-traces.js node tests/unit/test-url-state.js +node tests/unit/test-user-management-ui.js node tests/unit/test-warmup-banner.js node tests/unit/test-ws-stale-watchdog-1074.js node tests/unit/test-xss-escape-sinks.js diff --git a/tests/e2e/test-user-management-e2e.js b/tests/e2e/test-user-management-e2e.js new file mode 100644 index 00000000..2b9249b1 --- /dev/null +++ b/tests/e2e/test-user-management-e2e.js @@ -0,0 +1,152 @@ +/** + * E2E: optional user management (docs/specs/2026-10-06-user-management-design.md). + * BASE_URL server with userManagement on + fake mailer (-tags e2etest build) + * BASE_URL_OFF the regular fixture server (feature off) + * + * Local run (never point the servers at the tracked fixture; migrate a copy): + * cp test-fixtures/e2e-fixture.db "$TMP/on.db"; cp test-fixtures/e2e-fixture.db "$TMP/off.db" + * corescope-migrate -db "$TMP/on.db"; corescope-migrate -db "$TMP/off.db" + * (cd cmd/server && go build -o ../../corescope-server . && go build -tags e2etest -o ../../corescope-server-e2e .) + * # config.json for the on-server (in $CFGDIR): port 13582, userManagement {enabled: true, + * # dbPath "users.db", adminEmails ["admin@e2e.test"], publicBaseUrl "http://localhost:13582", + * # mail {provider "fake", fromEmail "noreply@e2e.test"}} + * corescope-server -port 13581 -db "$TMP/off.db" -public public & + * (cd "$CFGDIR" && corescope-server-e2e -config-dir . -port 13582 -db "$TMP/on.db" -public /public) & + * BASE_URL=http://localhost:13582 BASE_URL_OFF=http://localhost:13581 node tests/e2e/test-user-management-e2e.js + * Set CHROMIUM_PATH to a Chrome/Chromium binary if Playwright's own is not installed. + */ +'use strict'; +const { chromium } = require('playwright'); +const { AxeBuilder } = require('@axe-core/playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:13582'; +const BASE_OFF = process.env.BASE_URL_OFF || 'http://localhost:13581'; +const PW = 'correct horse battery'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// Resolves once auth.js has finished its first /api/auth/me round trip. +async function authReady(page) { + await page.waitForFunction(() => !!window.CSAuth); + await page.evaluate(() => window.CSAuth.ready()); +} + +async function lastMailToken(page) { + const r = await page.request.get(BASE + '/__e2e/last-mail'); + assert(r.ok(), 'last-mail HTTP ' + r.status()); + const m = await r.json(); + const sm = /token=([A-Za-z0-9_%-]+)/.exec(m.text); + assert(sm, 'no token in mail text: ' + m.text); + return { to: m.to, token: decodeURIComponent(sm[1]) }; +} + +async function registerAndActivate(page, email, name) { + await page.goto(BASE + '/#/account/register', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#registerForm'); + await page.fill('#regEmail', email); + await page.fill('#regName', name); + await page.fill('#regPassword', PW); + await page.click('#registerForm button[type="submit"]'); + await page.waitForSelector('#mailSentHeading'); + const { to, token } = await lastMailToken(page); + assert(to === email, 'activation mail went to ' + to); + await page.goto(BASE + '/#/account/activate?token=' + encodeURIComponent(token)); + await page.waitForSelector('#activateForm'); + await page.fill('#actPassword', PW); + await page.click('#activateForm button[type="submit"]'); + await page.waitForSelector('#accountToggle .nav-account-label:has-text("' + name + '")'); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== user management E2E against ${BASE} (off: ${BASE_OFF}) ===`); + + const off = await (await browser.newContext()).newPage(); + off.setDefaultTimeout(8000); + await step('feature off: no account control and no auth API', async () => { + await off.goto(BASE_OFF + '/', { waitUntil: 'domcontentloaded' }); + await authReady(off); + assert(await off.locator('#accountToggle').count() === 0, 'account control rendered while off'); + assert(await off.evaluate(() => !window.CSAuth.isEnabled()), 'CSAuth enabled while off'); + // Unknown /api paths fall through to the SPA page (200 HTML): only JSON with a csrfToken would be a leak. + const r = await off.request.get(BASE_OFF + '/api/auth/me'); + let body = null; + try { body = await r.json(); } catch (_) { /* HTML, expected */ } + assert(!(body && body.csrfToken), '/api/auth/me answered a session while off'); + }); + + const admin = await (await browser.newContext()).newPage(); + admin.setDefaultTimeout(8000); + admin.on('dialog', (d) => d.accept()); + admin.on('pageerror', (e) => console.error('[pageerror admin]', e.message)); + await step('config admin registers, activates with a password, sees the Users entry', async () => { + await registerAndActivate(admin, 'admin@e2e.test', 'E2E Admin'); + await admin.click('#accountToggle'); + assert(await admin.locator('#accountMenu a[href="#/admin/users"]').isVisible(), 'no Users menu entry'); + }); + + const user = await (await browser.newContext()).newPage(); + user.setDefaultTimeout(8000); + user.on('pageerror', (e) => console.error('[pageerror user]', e.message)); + await step('second user registers and activates; no Users entry for a non-admin', async () => { + await registerAndActivate(user, 'user@e2e.test', 'E2E User'); + await user.click('#accountToggle'); + assert(await user.locator('#accountMenu').isVisible(), 'account menu did not open'); + assert(await user.locator('#accountMenu a[href="#/admin/users"]').count() === 0, 'non-admin sees Users'); + }); + + await step('user logs out from the account page (phone width) and logs in again', async () => { + // At phone width the header control is hidden: the page button is the only way out. + await user.setViewportSize({ width: 375, height: 800 }); + await user.goto(BASE + '/#/account'); + await user.waitForSelector('#profileForm'); + await user.click('#accountPageLogout'); + await user.waitForSelector('#loginForm'); + assert(await user.evaluate(() => location.hash) === '#/account/login', 'not on the login view after logout'); + assert(await user.evaluate(() => window.CS_USER === null), 'client still holds the user'); + await user.setViewportSize({ width: 1280, height: 720 }); + await user.waitForSelector('#accountToggle .nav-account-label:has-text("Log in")'); + await user.fill('#loginEmail', 'user@e2e.test'); + await user.fill('#loginPassword', PW); + await user.click('#loginForm button[type="submit"]'); + await user.waitForSelector('#profileForm'); + await user.waitForSelector('#accountToggle .nav-account-label:has-text("E2E User")'); + }); + + await step('admin disables the user; the live session is logged out without a reload', async () => { + await admin.goto(BASE + '/#/admin/users'); + const row = admin.locator('tr[data-email="user@e2e.test"]'); + await row.waitFor(); + await row.locator('button[data-act="disable"]').click(); + await admin.waitForSelector('tr[data-email="user@e2e.test"] .um-status-disabled'); + // No reload: leave and re-enter the account page; its sessions call answers 401. + await user.goto(BASE + '/#/home'); + await user.goto(BASE + '/#/account'); + await user.waitForSelector('.cs-auth-toast.visible:has-text("You were logged out.")'); + await user.waitForSelector('#accountToggle .nav-account-label:has-text("Log in")'); + }); + + await step('axe: no serious or critical violations on the new views', async () => { + for (const [pg, route, sel] of [[user, '/#/account/login', '#loginForm'], [admin, '/#/admin/users', '.um-table']]) { + await pg.goto(BASE + route); + await pg.waitForSelector(sel); + await authReady(pg); + await pg.waitForTimeout(1500); + const res = await new AxeBuilder({ page: pg }).include('#app').withTags(['wcag2a', 'wcag2aa']).analyze(); + const bad = res.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical'); + assert(bad.length === 0, route + ': ' + bad.map((v) => v.id + ' ' + v.nodes.map((n) => n.target.join(' ') + ' ' + ((n.any[0] || {}).message || '')).join(' | ')).join(', ')); + } + }); + + await browser.close(); + console.log('\n' + passed + '/' + (passed + failed) + ' tests passed'); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/unit/test-customizer-v2.js b/tests/unit/test-customizer-v2.js index 954491ec..6393f856 100644 --- a/tests/unit/test-customizer-v2.js +++ b/tests/unit/test-customizer-v2.js @@ -580,6 +580,125 @@ test('validateShape rejects non-array myNodes', () => { assert.ok(!result.valid, 'non-array myNodes should be invalid'); }); +// ── geofilter editor access (optional user management) ── +console.log('_gfCanEdit:'); +test('_gfCanEdit: writeEnabled key allows edit', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api._gfCanEdit({ writeEnabled: true }, undefined), true); +}); +test('_gfCanEdit: admin session allows edit without writeEnabled', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api._gfCanEdit({ writeEnabled: false }, { isAdmin: () => true }), true); +}); +test('_gfCanEdit: non-admin session and no key denies edit', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api._gfCanEdit({ writeEnabled: false }, { isAdmin: () => false }), false); + assert.strictEqual(api._gfCanEdit(null, undefined), false); +}); + + +// _gfAuthHeaders: an admin session replaces the API key (CSRF header, no key). +function gfContainer(apiKey) { + return { querySelector: (sel) => (sel === '#cv2-gf-apikey' ? { value: apiKey } : null) }; +} +const ADMIN = { isAdmin: () => true, adminHeaders: () => ({ 'X-CS-CSRF': 'csrf1' }) }; +const USER = { isAdmin: () => false, adminHeaders: () => ({}) }; +console.log('_gfAuthHeaders:'); +test('_gfAuthHeaders: admin session sends X-CS-CSRF and no X-API-Key', () => { + const { api } = loadCustomizer(); + const h = api._gfAuthHeaders(gfContainer('typed-key'), { 'Content-Type': 'application/json' }, ADMIN); + assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'Content-Type': 'application/json', 'X-CS-CSRF': 'csrf1' }); +}); +test('_gfAuthHeaders: non-admin with a typed key sends X-API-Key', () => { + const { api } = loadCustomizer(); + const h = api._gfAuthHeaders(gfContainer('typed-key'), {}, USER); + assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'X-API-Key': 'typed-key' }); +}); +test('_gfAuthHeaders: no feature and a typed key sends X-API-Key', () => { + const { api } = loadCustomizer(); + const h = api._gfAuthHeaders(gfContainer('typed-key'), {}, undefined); + assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'X-API-Key': 'typed-key' }); +}); +test('_gfAuthHeaders: neither session nor key returns null', () => { + const { api } = loadCustomizer(); + assert.strictEqual(api._gfAuthHeaders(gfContainer(''), {}, USER), null); + assert.strictEqual(api._gfAuthHeaders(gfContainer(''), {}, undefined), null); +}); + +// _gfApplyAuth: edit controls, prune section and key field follow the +// current auth state, so a later login or logout is picked up. +function gfPanel() { + const els = { + '#cv2-gf-edit': { style: { display: 'none' } }, + '#cv2-gf-prune-section': { style: { display: 'none' } }, + '#cv2-gf-apikey-field': { hidden: false }, + }; + return { els, querySelector: (sel) => els[sel] || null }; +} +console.log('_gfApplyAuth:'); +test('_gfApplyAuth: a login after load shows the editor and hides the key field', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + api._gfApplyAuth(c, { writeEnabled: false }, USER, 0); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false); + api._gfApplyAuth(c, { writeEnabled: false }, ADMIN, 4); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, true); +}); +test('_gfApplyAuth: a logout hides the editor again when the server has no write key', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + api._gfApplyAuth(c, { writeEnabled: false }, ADMIN, 4); + api._gfApplyAuth(c, { writeEnabled: false }, USER, 4); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false); +}); +test('_gfApplyAuth: a write key keeps the editor without a session; prune needs 3 points', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + api._gfApplyAuth(c, { writeEnabled: true }, undefined, 2); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false); +}); + +// _gfReapply: the cached re-render and later auth changes. Feature off it +// does nothing (master kept the controls hidden until a reload); feature on +// it sizes the prune section from the saved server polygon. +const ADMIN_ON = { isEnabled: () => true, isAdmin: () => true, adminHeaders: () => ({}) }; +const OFF = { isEnabled: () => false, isAdmin: () => false, adminHeaders: () => ({}) }; +const SAVED4 = { writeEnabled: true, polygon: [[1, 1], [1, 2], [2, 2], [2, 1]] }; +console.log('_gfReapply:'); +test('_gfReapply: feature off leaves edit controls and prune hidden on a cached re-render', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + assert.strictEqual(api._gfReapply(c, SAVED4, OFF), false); + assert.strictEqual(api._gfReapply(c, SAVED4, undefined), false); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none'); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false); +}); +test('_gfReapply: feature on with an admin shows edit controls and prune for a saved polygon', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + assert.strictEqual(api._gfReapply(c, { writeEnabled: false, polygon: SAVED4.polygon }, ADMIN_ON), true); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, true); +}); +test('_gfReapply: prune follows the saved polygon, not unsaved drawing', () => { + const { api } = loadCustomizer(); + const c = gfPanel(); + api._gfReapply(c, { writeEnabled: false, polygon: null }, ADMIN_ON); + assert.strictEqual(c.els['#cv2-gf-edit'].style.display, ''); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none'); + api._gfReapply(c, { writeEnabled: false, polygon: [[1, 1], [1, 2]] }, ADMIN_ON); + assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none'); +}); + // ── Summary ── console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/unit/test-user-management-ui.js b/tests/unit/test-user-management-ui.js new file mode 100644 index 00000000..2725165d --- /dev/null +++ b/tests/unit/test-user-management-ui.js @@ -0,0 +1,794 @@ +/* Unit tests for the optional user-management UI (public/auth.js). Real file + * loaded in a vm sandbox, same pattern as test-frontend-helpers.js. */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.resolve(__dirname, '..', '..'); +let passed = 0, failed = 0; +const pending = []; +function test(name, fn) { + const p = Promise.resolve().then(fn).then( + () => { passed++; console.log(' ok ' + name); }, + (e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); }); + pending.push(p); + return p; +} + +// escapeHtml comes from the real app.js, not a copy. +function loadEscapeHtml() { + const src = fs.readFileSync(path.join(ROOT, 'public/app.js'), 'utf8'); + const m = src.match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/); + assert(m, 'escapeHtml not found in app.js'); + return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')'); +} + +function makeEnv(routes) { + const els = {}; + const events = []; + const mkEl = (id) => ({ + id: id || '', className: '', innerHTML: '', textContent: '', hidden: false, + classList: { add() {}, remove() {} }, + style: {}, listeners: {}, setAttribute() {}, appendChild() {}, + addEventListener(t, f) { this.listeners[t] = f; }, + }); + const right = { insertBefore(el) { els[el.id] = el; } }; + const doc = { + getElementById(id) { + if (id === 'hamburger') return null; + if (id === 'accountWrap') return els.accountWrap || null; + if (id === 'csAuthToast') return els.csAuthToast || null; + return els[id] || mkEl(id); + }, + querySelector(sel) { return sel === '.top-nav .nav-right' ? right : null; }, + createElement() { return mkEl(); }, + addEventListener() {}, + body: { appendChild(el) { els[el.id] = el; } }, + }; + const calls = []; + const win = { + addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); }, + innerWidth: 1000, listeners: {}, + dispatchEvent(e) { events.push(e); }, + MC_USER_MGMT: { enabled: true }, + MeshConfigReady: Promise.resolve(), + }; + win.window = win; + const loc = { hash: '#/account' }; + const ctx = { + window: win, document: doc, console, Promise, JSON, String, Object, location: loc, + setTimeout, clearTimeout, + CustomEvent: function (type, init) { this.type = type; this.detail = init && init.detail; }, + escapeHtml: loadEscapeHtml(), + fetch(url, opts) { + calls.push({ url, opts }); + const r = routes(url, opts) || { status: 404, body: {} }; + return Promise.resolve({ ok: r.status < 400, status: r.status, json: () => Promise.resolve(r.body) }); + }, + }; + ctx.globalThis = ctx; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8'), ctx); + return { win, els, events, calls, loc }; +} + +const ME = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user', csrfToken: 'tok123' }; + +console.log('auth.js'); + +test('inert when the server does not advertise userManagement', async () => { + const calls = []; + const win = { addEventListener() {}, dispatchEvent() {}, MC_USER_MGMT: null, MeshConfigReady: Promise.resolve() }; + win.window = win; + const ctx = { window: win, document: { addEventListener() {}, getElementById() { return null; }, querySelector() { return null; } }, + console, Promise, JSON, String, Object, setTimeout, clearTimeout, CustomEvent: function () {}, + escapeHtml: loadEscapeHtml(), fetch() { calls.push(1); return Promise.reject(new Error('no')); } }; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/auth.js'), 'utf8'), ctx); + await win.CSAuth.ready(); + assert.strictEqual(win.CSAuth.isEnabled(), false); + assert.strictEqual(calls.length, 0); +}); + +test('account menu opens as fixed, positioned from the toggle rect, and follows a resize', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null); + await env.win.CSAuth.ready(); + const toggle = { id: 'accountToggle', listeners: {}, setAttribute() {}, addEventListener(t, f) { this.listeners[t] = f; }, + getBoundingClientRect: () => ({ bottom: 50, right: 900 }) }; + const menu = { id: 'accountMenu', hidden: true, style: {}, listeners: {}, addEventListener() {} }; + env.els.accountToggle = toggle; env.els.accountMenu = menu; env.els.accountLogout = { addEventListener() {} }; + env.win.CSAuth._test.renderControl(); + toggle.listeners.click({ stopPropagation() {} }); + assert.strictEqual(menu.hidden, false); + assert.strictEqual(menu.style.top, '54px'); + assert.strictEqual(menu.style.right, '100px'); + assert.strictEqual(menu.style.left, 'auto'); + toggle.getBoundingClientRect = () => ({ bottom: 60, right: 800 }); + env.win.listeners.resize.forEach((f) => f()); + assert.strictEqual(menu.style.top, '64px'); + assert.strictEqual(menu.style.right, '200px'); +}); + +test('login state is loaded from /api/auth/me and announced', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null); + await env.win.CSAuth.ready(); + assert.strictEqual(env.win.CS_USER.displayName, 'Ann'); + assert(env.events.some((e) => e.type === 'cs-auth-changed')); +}); + +test('CSRF header only on non-GET requests', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: {} }); + await env.win.CSAuth.ready(); + env.calls.length = 0; + await env.win.CSAuth.request('GET', '/api/x'); + await env.win.CSAuth.request('POST', '/api/x', { a: 1 }); + assert.strictEqual(env.calls[0].opts.headers['X-CS-CSRF'], undefined); + assert.strictEqual(env.calls[1].opts.headers['X-CS-CSRF'], 'tok123'); + assert.strictEqual(env.calls[1].opts.body, '{"a":1}'); +}); + +test('401 on an authenticated call clears the user and toasts', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: { error: 'x' } }); + await env.win.CSAuth.ready(); + const r = await env.win.CSAuth.request('GET', '/api/auth/sessions'); + assert.strictEqual(r.status, 401); + assert.strictEqual(env.win.CS_USER, null); + assert.strictEqual(env.els.csAuthToast.textContent, 'You were logged out.'); +}); + +test('401 from the login call itself keeps state and shows no toast', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: {} }); + await env.win.CSAuth.ready(); + await env.win.CSAuth.request('POST', '/api/auth/login', { email: 'a', password: 'b' }); + assert.strictEqual(env.win.CS_USER.displayName, 'Ann'); + assert.strictEqual(env.els.csAuthToast, undefined); +}); + +test('401 without a logged-in user shows no toast', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 401, body: {} } : { status: 401, body: {} }); + await env.win.CSAuth.ready(); + await env.win.CSAuth.request('GET', '/api/x'); + assert.strictEqual(env.els.csAuthToast, undefined); +}); + +test('display name is escaped in the header label', async () => { + const payload = ''; + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: Object.assign({}, ME, { displayName: payload }) } : null); + await env.win.CSAuth.ready(); + const html = env.els.accountWrap.innerHTML; + assert(html.indexOf(' { + const user = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : null); + await user.win.CSAuth.ready(); + assert(user.els.accountWrap.innerHTML.indexOf('#/admin/users') === -1); + const admin = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: Object.assign({}, ME, { role: 'admin' }) } : null); + await admin.win.CSAuth.ready(); + assert(admin.els.accountWrap.innerHTML.indexOf('#/admin/users') !== -1); +}); + +test('logged-out header is a login link', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 401, body: {} } : null); + await env.win.CSAuth.ready(); + assert(env.els.accountWrap.innerHTML.indexOf('href="#/account/login"') !== -1); +}); + +test('401 from the activate call (wrong password) keeps the session and shows no toast', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 401, body: { error: 'wrong password for this account' } }); + await env.win.CSAuth.ready(); + await env.win.CSAuth.request('POST', '/api/auth/activate', { token: 't', password: 'p' }); + assert.strictEqual(env.win.CS_USER.displayName, 'Ann'); + assert.strictEqual(env.els.csAuthToast, undefined); +}); + +test('logout posts with CSRF, moves to the next view, then clears the user', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 200, body: { ok: true } }); + await env.win.CSAuth.ready(); + env.calls.length = 0; + let hashAtClear = null; + env.win.dispatchEvent = (e) => { if (e.type === 'cs-auth-changed') hashAtClear = env.loc.hash; }; + const r = await env.win.CSAuth.logout('#/account/login'); + assert.strictEqual(r.ok, true); + assert.strictEqual(env.calls[0].url, '/api/auth/logout'); + assert.strictEqual(env.calls[0].opts.method, 'POST'); + assert.strictEqual(env.calls[0].opts.headers['X-CS-CSRF'], 'tok123'); + assert.strictEqual(env.win.CS_USER, null); + assert.strictEqual(hashAtClear, '#/account/login', 'hash must change before cs-auth-changed fires'); +}); + +test('a refused logout keeps the user and the view', async () => { + const env = makeEnv((u) => u === '/api/auth/me' ? { status: 200, body: ME } : { status: 403, body: { error: 'request origin not allowed' } }); + await env.win.CSAuth.ready(); + const r = await env.win.CSAuth.logout('#/account/login'); + assert.strictEqual(r.status, 403); + assert.strictEqual(env.win.CS_USER.displayName, 'Ann'); + assert.strictEqual(env.loc.hash, '#/account'); +}); + +console.log('mobile nav account entry'); + +// Slice the real route tables and builders out of the two files; markers +// failing to match throws instead of silently testing nothing. +function sliceBetween(file, from, to) { + const src = fs.readFileSync(path.join(ROOT, 'public', file), 'utf8'); + const a = src.indexOf(from), b = src.indexOf(to); + assert(a !== -1 && b > a, 'markers moved in ' + file); + return src.slice(a, b); +} +function loadNav(file, from, to, fn, win) { + const sb = { window: Object.assign({ addEventListener() {} }, win), console }; + vm.createContext(sb); + vm.runInContext(sliceBetween(file, from, to) + '\nthis.fn = ' + fn + ';', sb); + return sb.fn; +} +const NAVS = [ + { name: 'bottom-nav moreRoutes', file: 'bottom-nav.js', from: 'var MORE_ROUTES = [', to: 'var SHEET_ID', fn: 'moreRoutes' }, + { name: 'nav-drawer routes', file: 'nav-drawer.js', from: 'var ROUTES = [', to: 'function phIconHTML', fn: 'routes' }, +]; +NAVS.forEach((n) => { + const get = (win) => loadNav(n.file, n.from, n.to, n.fn, win)(); + const acct = (list) => list.filter((r) => r.route === 'account'); + test(n.name + ': no account entry when user management is off', () => { + assert.strictEqual(acct(get({})).length, 0); + }); + test(n.name + ': Log in entry when logged out', () => { + const a = acct(get({ MC_USER_MGMT: { enabled: true } })); + assert.strictEqual(a.length, 1); + assert.strictEqual(a[0].hash, '#/account/login'); + assert.strictEqual(a[0].label, 'Log in'); + }); + test(n.name + ': My account entry when logged in', () => { + const a = acct(get({ MC_USER_MGMT: { enabled: true }, CS_USER: { id: 1 } })); + assert.strictEqual(a.length, 1); + assert.strictEqual(a[0].hash, '#/account'); + assert.strictEqual(a[0].label, 'My account'); + }); + test(n.name + ': coverage insert still present after analytics', () => { + const list = get({ MC_CLIENT_RX_COVERAGE: true, MC_USER_MGMT: { enabled: true } }); + const i = list.findIndex((r) => r.route === 'analytics'); + assert.strictEqual(list[i + 1].route, 'rx-coverage'); + assert.strictEqual(list.length, get({}).length + 2); + }); +}); + +console.log('account.js'); + +function loadAccount(hash, routes) { + const els = {}; + const mk = () => { + const el = { value: '', textContent: '', handlers: {}, cls: {}, html: '' }; + el.classList = { toggle(c, on) { el.cls[c] = !!on; } }; + el.addEventListener = (t, fn) => { el.handlers[t] = fn; }; + el.querySelector = () => null; + el.insertAdjacentHTML = (pos, html) => { els.renewLink = { html }; }; + return el; + }; + const doc = { getElementById(id) { return els[id] || (id === 'renewLink' ? null : (els[id] = mk())); } }; + let pages = {}; + const calls = []; + const user = { current: null }; + const logouts = []; + let logoutResult = { ok: true, status: 200, data: { ok: true } }; + let override = null; + const loc = { hash, replace(h) { loc.hash = h; } }; + const replaced = []; + const listeners = {}; + const CSAuth = { + request(method, p, body) { calls.push({ method, p, body }); return override ? override(method, p, body) : Promise.resolve(routes(p, body)); }, + setUser(u) { user.current = u; }, + user() { return user.current; }, + logout(next) { logouts.push(next); return logoutResult instanceof Error ? Promise.reject(logoutResult) : Promise.resolve(logoutResult); }, + ready() { return Promise.resolve(); }, isEnabled() { return true; }, + notify() {}, refreshMe() { return Promise.resolve(); }, + }; + const win = { CSAuth, addEventListener(t, fn) { listeners[t] = fn; } }; + const ctx = { window: win, document: doc, CSAuth, location: loc, URLSearchParams, Promise, String, Object, + history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } }, + escapeHtml: loadEscapeHtml(), registerPage(n, m) { pages[n] = m; }, console }; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/account.js'), 'utf8'), ctx); + return { setRequest(f) { override = f; }, doc, t: ctx.window.CSAccount._test, els, calls, loc, user, pages, logouts, replaced, + setLogout(r) { logoutResult = r; }, + fire(detail) { user.current = detail; if (listeners['cs-auth-changed']) listeners['cs-auth-changed']({ detail }); } }; +} +const submitForm = async (env, formId) => { + await env.els[formId].handlers.submit({ preventDefault() {} }); +}; + +test('profile view escapes email and session fields', () => { + const env = loadAccount('#/account', () => ({})); + const payload = ''; + const html = env.t.profileHtml({ email: payload, role: 'admin', displayName: payload }); + assert(html.indexOf('', userAgent: payload, lastSeenAt: 'x', current: false }]); + assert(sess.indexOf('') === -1, 'raw tag in sessions HTML: ' + sess); +}); + +test('sessions list marks the current device without a logout button', () => { + const env = loadAccount('#/account', () => ({})); + const h = env.t.sessionsHtml([{ id: 1, userAgent: 'A', lastSeenAt: '2026-01-01T00:00:00Z', current: true }, + { id: 2, userAgent: '', lastSeenAt: '2026-01-01T00:00:00Z', current: false }]); + assert.strictEqual((h.match(/data-sess=/g) || []).length, 1); + assert(h.indexOf('data-sess="2"') !== -1 && h.indexOf('Unknown device') !== -1); +}); + +test('activate posts token and password, logs in on success', async () => { + const env = loadAccount('#/account/activate?token=T0K', () => ({ ok: true, status: 200, data: { id: 1, displayName: 'Ann' } })); + env.t.views.activate({}); + env.doc.getElementById('actPassword').value = 'hunter2hunter2'; + await submitForm(env, 'activateForm'); + assert.strictEqual(JSON.stringify(env.calls[0]), JSON.stringify({ method: 'POST', p: '/api/auth/activate', body: { token: 'T0K', password: 'hunter2hunter2' } })); + assert.strictEqual(env.user.current.displayName, 'Ann'); + assert.strictEqual(env.loc.hash, '#/account'); +}); + +test('activate 401 shows the wrong-password message and stays on the form', async () => { + const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 401, data: { error: 'wrong password for this account' } })); + env.t.views.activate({}); + await submitForm(env, 'activateForm'); + assert.strictEqual(env.els.accountMsg.textContent, 'Wrong password for this account'); + assert.strictEqual(env.user.current, null); + assert.strictEqual(env.loc.hash, '#/account/activate'); // token stripped, form kept +}); + +test('activate 410 offers a new registration link', async () => { + const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 410, data: { error: 'this link has expired' } })); + env.t.views.activate({}); + await submitForm(env, 'activateForm'); + assert(env.els.renewLink.html.indexOf('href="#/account/register"') !== -1); + assert(env.els.renewLink.html.indexOf('Register again to get a new link') !== -1); +}); + +test('reset 410 offers the forgot-password link', async () => { + const env = loadAccount('#/account/reset?token=T', () => ({ ok: false, status: 410, data: { error: 'this link has expired' } })); + env.t.views.reset({}); + env.doc.getElementById('resetPassword').value = 'abcdefghijkl'; + env.doc.getElementById('resetPassword2').value = 'abcdefghijkl'; + await submitForm(env, 'resetForm'); + assert.strictEqual(JSON.stringify(env.calls[0].body), JSON.stringify({ token: 'T', password: 'abcdefghijkl' })); + assert(env.els.renewLink.html.indexOf('href="#/account/forgot"') !== -1); +}); + +test('reset refuses mismatching passwords without a request', async () => { + const env = loadAccount('#/account/reset?token=T', () => ({ ok: true, status: 200, data: {} })); + env.t.views.reset({}); + env.doc.getElementById('resetPassword').value = 'abcdefghijkl'; + env.doc.getElementById('resetPassword2').value = 'different-pass'; + await submitForm(env, 'resetForm'); + assert.strictEqual(env.calls.length, 0); + assert.strictEqual(env.els.accountMsg.textContent, 'The passwords do not match.'); +}); + +test('activate without a token renders no form and no password input', () => { + const env = loadAccount('#/account/activate', () => ({})); + let html = ''; + env.t.views.activate({ set innerHTML(v) { html = v; } }); + assert(html.indexOf(' { + const env = loadAccount('#/account', () => ({})); + assert(!/]* name=/.test(env.t.profileHtml({ email: 'a', role: 'user' }))); + let html = ''; + env.t.views.register({ set innerHTML(v) { html = v; } }); + assert(!/]* name=/.test(html)); +}); + +const flush = () => new Promise((r) => setTimeout(r, 5)); + +const render = (env, name) => { let html = ''; env.t.views[name]({ set innerHTML(v) { html = v; } }); return html; }; + +test('register success goes to check-mail, which shows the escaped email and no form', async () => { + const payload = '@e.c'; + const env = loadAccount('#/account/register', () => ({ ok: true, status: 200, data: { message: 'x' } })); + env.t.views.register({ set innerHTML(v) {} }); + env.doc.getElementById('regEmail').value = payload; + await submitForm(env, 'registerForm'); + await flush(); + assert.strictEqual(env.loc.hash, '#/account/check-mail'); + const html = render(env, 'check-mail'); + assert(html.indexOf('Check your mailbox') !== -1, html); + assert(html.indexOf(' { + const env = loadAccount('#/account/check-mail', () => ({ ok: true, status: 200, data: {} })); + const html = render(env, 'check-mail'); + assert(html.indexOf('If the address can be used, we sent you a link. Check your mailbox.') !== -1, html); + assert(html.indexOf('') === -1 && html.indexOf(' { + const payload = '@e.c'; + const env = loadAccount('#/account/forgot', () => ({ ok: true, status: 200, data: { message: 'x' } })); + env.t.views.forgot({ set innerHTML(v) {} }); + env.doc.getElementById('forgotEmail').value = payload; + await submitForm(env, 'forgotForm'); + await flush(); + assert.strictEqual(env.loc.hash, '#/account/check-mail'); + const html = render(env, 'check-mail'); + assert(html.indexOf('Check your mailbox') !== -1 && html.indexOf(' { + const env = loadAccount('#/account/register', () => ({ ok: false, status: 400, data: { error: 'Bad password' } })); + let html = ''; + env.t.views.register({ set innerHTML(v) { html = v; } }); + await submitForm(env, 'registerForm'); + await flush(); + assert(html.indexOf(' { + const env = loadAccount('#/account', () => ({})); + env.user.current = { email: 'a@b.c', displayName: 'Ann', role: 'user' }; + let n = 0; + env.calls.length = 0; + const req = () => (++n === 1 ? Promise.resolve({ ok: true, status: 200, data: [] }) : Promise.reject(new Error('net'))); + env.setRequest(req); + env.t.views.profile({ set innerHTML(v) {} }); + await new Promise((r) => setTimeout(r, 5)); + await submitForm(env, 'pwForm'); + assert.strictEqual(env.els.pwMsg.textContent, 'Network error, try again.'); +}); + +test('a rejected sessions load shows the error in sessMsg', async () => { + const env = loadAccount('#/account', () => ({})); + env.user.current = { email: 'a@b.c', displayName: 'Ann', role: 'user' }; + env.setRequest(() => Promise.reject(new Error('net'))); + env.t.views.profile({ set innerHTML(v) {} }); + await new Promise((r) => setTimeout(r, 5)); + assert.strictEqual(env.els.sessMsg.textContent, 'Network error, try again.'); +}); + +test('profile view without a user redirects to login', () => { + const env = loadAccount('#/account', () => ({})); + env.t.views.profile({}); + assert.strictEqual(env.loc.hash, '#/account/login'); +}); + +test('profile view: Log out button logs out to the login view', async () => { + const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] })); + env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' }; + env.t.views.profile({ set innerHTML(v) {} }); + await env.els.accountPageLogout.handlers.click(); + assert.deepStrictEqual(env.logouts, ['#/account/login']); +}); + +test('profile view: a refused logout shows the error next to the button', async () => { + const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] })); + env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' }; + env.setLogout({ ok: false, status: 403, data: { error: 'request origin not allowed' } }); + env.t.views.profile({ set innerHTML(v) {} }); + await env.els.accountPageLogout.handlers.click(); + assert.strictEqual(env.els.logoutMsg.textContent, 'request origin not allowed'); + env.setLogout(new Error('net')); + await env.els.accountPageLogout.handlers.click(); + assert.strictEqual(env.els.logoutMsg.textContent, 'Network error, try again.'); +}); + +test('profile view: Log out button for everyone, Manage users link for admins only', () => { + const env = loadAccount('#/account', () => ({})); + const user = env.t.profileHtml({ email: 'a', role: 'user', displayName: 'A' }); + const admin = env.t.profileHtml({ email: 'a', role: 'admin', displayName: 'A' }); + assert(user.indexOf('id="accountPageLogout"') !== -1 && admin.indexOf('id="accountPageLogout"') !== -1); + assert(user.indexOf('#/admin/users') === -1); + assert(admin.indexOf('href="#/admin/users"') !== -1 && admin.indexOf('Manage users') !== -1); +}); + +test('profile view follows auth changes: logout redirects, another user re-renders', () => { + const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] })); + env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' }; + let renders = 0; + env.t.views.profile({ set innerHTML(v) { renders++; } }); + env.fire({ id: 1, email: 'a@b.c', displayName: 'Ann B', role: 'user' }); // same user (name saved): no re-render + assert.strictEqual(renders, 1); + env.fire({ id: 2, email: 'b@b.c', displayName: 'Bob', role: 'user' }); + assert.strictEqual(renders, 2); + assert.strictEqual(env.els.profName.value, 'Bob'); + env.fire(null); + assert.strictEqual(env.loc.hash, '#/account/login'); +}); + +test('auth changes on other views are ignored', () => { + const env = loadAccount('#/account', () => ({ ok: true, status: 200, data: [] })); + env.user.current = { id: 1, email: 'a@b.c', displayName: 'Ann', role: 'user' }; + env.t.views.profile({ set innerHTML(v) {} }); + env.loc.hash = '#/home'; + env.fire(null); + assert.strictEqual(env.loc.hash, '#/home'); +}); + +test('unknown and inherited view names fall back to the profile view', async () => { + for (const name of ['constructor', '__proto__', 'toString', 'nope']) { + const env = loadAccount('#/account/' + name, () => ({})); + env.pages.account.init({ set innerHTML(v) {} }, name); + await tick(); + assert.strictEqual(env.loc.hash, '#/account/login', name + ' did not reach the profile view'); + } +}); + +test('tokens are stripped from the hash once read', async () => { + const act = loadAccount('#/account/activate?token=T0K', () => ({ ok: true, status: 200, data: { id: 1 } })); + act.t.views.activate({ set innerHTML(v) {} }); + assert.strictEqual(act.loc.hash, '#/account/activate'); + await submitForm(act, 'activateForm'); + assert.strictEqual(act.calls[0].body.token, 'T0K'); + const reset = loadAccount('#/account/reset?token=R1', () => ({ ok: true, status: 200, data: { message: 'ok' } })); + reset.t.views.reset({ set innerHTML(v) {} }); + assert.strictEqual(reset.loc.hash, '#/account/reset'); + const conf = loadAccount('#/account/confirm-email?token=C1', () => ({ ok: true, status: 200, data: { message: 'ok' } })); + conf.t.views['confirm-email']({ set innerHTML(v) {} }); + assert.strictEqual(conf.loc.hash, '#/account/confirm-email'); + assert.strictEqual(conf.calls[0].body.token, 'C1'); +}); + +test('reset success clears the client user (the reset ended every session)', async () => { + const env = loadAccount('#/account/reset?token=T', () => ({ ok: true, status: 200, data: { message: 'Password changed.' } })); + env.user.current = { id: 1 }; + env.t.views.reset({ set innerHTML(v) {} }); + env.doc.getElementById('resetPassword').value = 'abcdefghijkl'; + env.doc.getElementById('resetPassword2').value = 'abcdefghijkl'; + await submitForm(env, 'resetForm'); + assert.strictEqual(env.user.current, null); + assert.strictEqual(env.els.accountMsg.textContent, 'Password changed.'); +}); + +test('activate 410 for an already active account offers no registration link', async () => { + const env = loadAccount('#/account/activate?token=T', () => ({ ok: false, status: 410, data: { error: 'this account is already activated, log in instead' } })); + env.t.views.activate({ set innerHTML(v) {} }); + await submitForm(env, 'activateForm'); + assert.strictEqual(env.els.renewLink, undefined); + assert.strictEqual(env.els.accountMsg.textContent, 'this account is already activated, log in instead'); +}); + +console.log('admin-users.js'); + +function loadAdmin(hash, routes) { + const els = {}; + const mk = (id) => { + const el = { id, value: '', textContent: '', innerHTML: '', hidden: false, handlers: {}, cls: {}, focused: 0 }; + el.classList = { toggle(c, on) { el.cls[c] = !!on; } }; + el.addEventListener = (t, fn) => { el.handlers[t] = fn; }; + el.focus = () => { el.focused++; }; + el.querySelector = (sel) => els[id + ' ' + sel] || (els[id + ' ' + sel] = mk(id + ' ' + sel)); + return el; + }; + const doc = { + getElementById(id) { return els[id] || (els[id] = mk(id)); }, + querySelector(sel) { return els[sel] || (els[sel] = mk(sel)); }, + }; + const pages = {}; + const calls = []; + const replaced = []; + const loc = { hash }; + let me = { id: 1, role: 'admin' }; + let refreshed = 0; + const listeners = {}; + const CSAuth = { + ready() { return Promise.resolve(); }, isEnabled() { return true; }, isAdmin() { return !!me && me.role === 'admin'; }, + user() { return me; }, + request(method, p, body) { calls.push({ method, p, body }); return Promise.resolve(routes(method, p, body)); }, + refreshMe() { refreshed++; me = { id: 1, role: 'user' }; return Promise.resolve(); }, + }; + const ctx = { window: { CSAuth, addEventListener(t, fn) { listeners[t] = fn; } }, document: doc, CSAuth, location: loc, URLSearchParams, Promise, String, + history: { replaceState(a, b, h) { replaced.push(h); loc.hash = h; } }, + confirm() { return true; }, debounce(fn) { return fn; }, + escapeHtml: loadEscapeHtml(), registerPage(n, m) { pages[n] = m; }, console }; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/admin-users.js'), 'utf8'), ctx); + const app = { innerHTML: '', querySelector() { return els.umPage || (els.umPage = mk('umPage')); } }; + return { t: ctx.window.CSAdminUsers._test, pages, els, calls, replaced, loc, app, refreshed: () => refreshed, + fire(detail) { me = detail; if (listeners['cs-auth-changed']) listeners['cs-auth-changed']({ detail }); } }; +} +const tick = () => new Promise((r) => setTimeout(r, 5)); +const U = (o) => Object.assign({ id: 2, email: 'u@x.y', displayName: 'U', role: 'user', status: 'active', configAdmin: false }, o); +const OK = (data) => ({ ok: true, status: 200, data: data }); +const acts = (env, u, me) => (env.t.actionsFor(u, me).match(/data-act="(\w+)"/g) || []).map((x) => x.slice(10, -1)); +const adminEnv = () => loadAdmin('#/admin/users', () => OK([])); + +test('actionsFor: pending offers activate/resend, no disable and no role buttons', () => { + assert.deepStrictEqual(acts(adminEnv(), U({ status: 'pending' }), { id: 1 }), ['detail', 'activate', 'resend', 'delete']); +}); +test('actionsFor: active user offers disable, promote, delete', () => { + assert.deepStrictEqual(acts(adminEnv(), U(), { id: 1 }), ['detail', 'disable', 'promote', 'delete']); +}); +test('actionsFor: active admin offers demote', () => { + assert.deepStrictEqual(acts(adminEnv(), U({ role: 'admin' }), { id: 1 }), ['detail', 'disable', 'demote', 'delete']); +}); +test('actionsFor: disabled offers enable, never disable', () => { + assert.deepStrictEqual(acts(adminEnv(), U({ status: 'disabled' }), { id: 1 }), ['detail', 'enable', 'promote', 'delete']); +}); +test('actionsFor: own row has no disable or delete but can change role', () => { + assert.deepStrictEqual(acts(adminEnv(), U({ id: 1, role: 'admin' }), { id: 1 }), ['detail', 'demote']); +}); +test('actionsFor: config admin has no disable, role or delete', () => { + assert.deepStrictEqual(acts(adminEnv(), U({ role: 'admin', configAdmin: true }), { id: 1 }), ['detail']); +}); +test('actionsFor: buttons use account-btn classes', () => { + const h = adminEnv().t.actionsFor(U(), { id: 1 }); + assert(h.indexOf('class="account-btn account-btn-secondary"') !== -1 && h.indexOf('btn-primary') === -1); +}); + +test('row and detail rendering escape every dynamic field', () => { + const env = adminEnv(); + const p = ''; + const row = env.t.rowHtml(U({ id: '1">', email: p, displayName: p, role: p, status: p, lastMail: { lastEvent: p, lastReason: p }, createdAt: p }), { id: 1 }); + assert(row.indexOf('') === -1, 'raw markup in row: ' + row); + const det = env.t.detailHtml({ user: { id: 1, displayName: p, email: p }, sessions: [], + mail: [{ id: '3">', purpose: p, to: p, sentAt: p, lastEvent: p, lastReason: p, events: [{ event: p, at: p, reason: p }] }], + audit: [{ at: p, action: p, actorUserId: p }] }); + assert(det.indexOf('') === -1, 'raw markup in detail: ' + det); + assert(det.indexOf('<img src=x onerror=alert(1)>') !== -1); +}); + +test('deep link: filters and detail id round-trip through the hash', () => { + const env = adminEnv(); + assert.deepStrictEqual(JSON.parse(JSON.stringify(env.t.readHash('#/admin/users?status=pending&role=admin&q=a%20b&id=7'))), + { status: 'pending', role: 'admin', q: 'a b', id: '7' }); + assert.strictEqual(env.t.hashFor({ status: 'pending', role: '', q: 'a b' }, '7'), '#/admin/users?status=pending&q=a+b&id=7'); + assert.strictEqual(env.t.hashFor({ status: '', role: '', q: '' }, null), '#/admin/users'); +}); + +test('init reads the hash into the request and detail; filter changes use replaceState', async () => { + const env = loadAdmin('#/admin/users?status=pending&id=7', (m, p) => + OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : [])); + env.pages.admin.init(env.app, 'users'); + await tick(); + assert(env.calls.some((c) => c.p === '/api/admin/users?status=pending'), JSON.stringify(env.calls)); + assert(env.calls.some((c) => c.p === '/api/admin/users/7')); + assert.strictEqual(env.els.umStatus.value, 'pending'); + env.els.umRole.handlers.change({ target: { value: 'admin' } }); + assert.strictEqual(env.loc.hash, '#/admin/users?status=pending&role=admin&id=7'); + assert(env.replaced.length >= 1); +}); + +test('a rejected list fetch shows an error in umMsg', async () => { + const env = loadAdmin('#/admin/users', () => Promise.reject(new Error('net'))); + env.pages.admin.init(env.app, 'users'); + await tick(); + assert.strictEqual(env.els.umMsg.textContent, 'Network error, try again.'); +}); + +test('a rejected action shows an error in umMsg', async () => { + let n = 0; + const env = loadAdmin('#/admin/users', () => (++n === 1 ? OK([]) : Promise.reject(new Error('net')))); + env.pages.admin.init(env.app, 'users'); + await tick(); + env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': 'disable', 'data-id': '2' })[a] }) } }); + await tick(); + assert.strictEqual(env.els.umMsg.textContent, 'Network error, try again.'); +}); + +test('demoting yourself refreshes the session and leaves the page', async () => { + const env = loadAdmin('#/admin/users', () => OK([])); + env.pages.admin.init(env.app, 'users'); + await tick(); + env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': 'demote', 'data-id': '1' })[a] }) } }); + await tick(); + assert.strictEqual(env.refreshed(), 1); + assert.strictEqual(env.loc.hash, '#/account'); +}); + +test('readHash accepts only numeric ids and known status and role values', () => { + const env = adminEnv(); + const h = (s) => JSON.parse(JSON.stringify(env.t.readHash('#/admin/users?' + s))); + assert.deepStrictEqual(h('status=bogus&role=root&id=7%3Bx&q=x'), { status: '', role: '', q: 'x', id: '' }); + assert.deepStrictEqual(h('id=abc'), { status: '', role: '', q: '', id: '' }); + assert.deepStrictEqual(h('status=disabled&role=user&id=12'), { status: 'disabled', role: 'user', q: '', id: '12' }); +}); + +const clickAct = (env, act, id) => env.els.umPage.handlers.click({ target: { closest: () => ({ getAttribute: (a) => ({ 'data-act': act, 'data-id': id })[a] }) } }); + +test('detail panel has a Close button; opening focuses the panel, closing returns focus', async () => { + const env = loadAdmin('#/admin/users', (m, p) => + OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : [])); + assert(env.t.detailHtml({ user: U({ id: 7 }), sessions: [], mail: [], audit: [] }).indexOf('data-act="close"') !== -1); + env.pages.admin.init(env.app, 'users'); + await tick(); + clickAct(env, 'detail', '7'); + await tick(); + assert.strictEqual(env.els.umDetail.hidden, false); + assert.strictEqual(env.els['umDetail h3'].focused, 1); + assert.strictEqual(env.loc.hash, '#/admin/users?id=7'); + clickAct(env, 'close', null); + assert.strictEqual(env.els.umDetail.hidden, true); + assert.strictEqual(env.loc.hash, '#/admin/users'); + assert.strictEqual(env.els['button[data-act="detail"][data-id="7"]'].focused, 1); +}); + +test('a deep-linked detail does not steal focus on load', async () => { + const env = loadAdmin('#/admin/users?id=7', (m, p) => + OK(p.indexOf('/api/admin/users/7') === 0 ? { user: U({ id: 7 }), sessions: [], mail: [], audit: [] } : [])); + env.pages.admin.init(env.app, 'users'); + await tick(); + assert.strictEqual(env.els.umDetail.hidden, false); + assert.strictEqual((env.els['umDetail h3'] || { focused: 0 }).focused, 0); +}); + +test('admin page follows auth changes: logout redirects to login, losing admin re-renders', async () => { + const env = adminEnv(); + env.pages.admin.init(env.app, 'users'); + await tick(); + env.fire({ id: 1, role: 'user' }); + await tick(); + assert(env.app.innerHTML.indexOf('Admins only') !== -1, env.app.innerHTML); + env.fire(null); + assert.strictEqual(env.loc.hash, '#/account/login'); +}); + +test('admin page ignores auth changes once left', async () => { + const env = adminEnv(); + env.pages.admin.init(env.app, 'users'); + await tick(); + env.pages.admin.destroy(); + env.loc.hash = '#/home'; + env.fire(null); + assert.strictEqual(env.loc.hash, '#/home'); +}); + +console.log('perf.js reset'); + +function loadPerf() { + const ctx = { window: { addEventListener() {} }, document: { getElementById() { return null; }, addEventListener() {} }, + console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error, Promise, Map, Set, + parseInt, parseFloat, isNaN, isFinite, setTimeout() {}, clearTimeout() {}, setInterval() { return 0; }, clearInterval() {}, + performance: { now: () => 0 }, registerPage() {} }; + ctx.globalThis = ctx; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(ROOT, 'public/perf.js'), 'utf8'), ctx); + return ctx; +} +function fakeFetch(status) { + const calls = []; + const fn = (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: status < 400, status }); }; + fn.calls = calls; + return fn; +} + +test('feature off: reset is the old plain POST, no alert, local stats cleared', async () => { + const ctx = loadPerf(); + const f = fakeFetch(401); + const alerts = []; + const ok = await ctx.resetPerfStats({ MC_USER_MGMT: null, CSAuth: { adminHeaders: () => ({}) } }, f, (m) => alerts.push(m)); + assert.strictEqual(ok, true); + assert.strictEqual(alerts.length, 0); + assert.strictEqual(JSON.stringify(f.calls[0]), JSON.stringify({ url: '/api/perf/reset', opts: { method: 'POST' } })); +}); + +test('feature on: an admin session authorises the reset', async () => { + const ctx = loadPerf(); + const f = fakeFetch(200); + const ok = await ctx.resetPerfStats({ MC_USER_MGMT: { enabled: true }, CSAuth: { adminHeaders: () => ({ 'X-CS-CSRF': 'c' }) } }, f, () => {}); + assert.strictEqual(ok, true); + assert.strictEqual(f.calls[0].opts.headers['X-CS-CSRF'], 'c'); +}); + +test('feature on: a refused reset alerts and keeps the stats', async () => { + const ctx = loadPerf(); + const alerts = []; + const ok = await ctx.resetPerfStats({ MC_USER_MGMT: { enabled: true }, CSAuth: { adminHeaders: () => ({}) } }, fakeFetch(403), (m) => alerts.push(m)); + assert.strictEqual(ok, false); + assert.deepStrictEqual(alerts, ['Reset failed: HTTP 403']); +}); + +Promise.all(pending).then(() => { + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +});