From 57a13f07598bc305046392ae12ac95c099a41ce9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:31:02 +0100 Subject: [PATCH] =?UTF-8?q?http:=20POST=20/setup=20=E2=80=94=20set=20passw?= =?UTF-8?q?ord,=20drop=20session,=20audit=20setup=5Fcompleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 501 stub with the full handler: validates the token and password, hashes and stores the password, deletes the setup token, mints an 8-hour session cookie, appends a user.setup_completed audit entry, and redirects to /. Adds TestSetupPostHappyPath covering the full round-trip including normal-login verification after setup. --- internal/server/http/setup_handler.go | 100 +++++++++++++++++++++++++- internal/server/http/setup_test.go | 62 ++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/internal/server/http/setup_handler.go b/internal/server/http/setup_handler.go index d2ab3b2..d5eb488 100644 --- a/internal/server/http/setup_handler.go +++ b/internal/server/http/setup_handler.go @@ -17,7 +17,11 @@ import ( stdhttp "net/http" "time" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type setupPage struct { @@ -71,7 +75,99 @@ func (s *Server) renderSetupExpired(w stdhttp.ResponseWriter, r *stdhttp.Request _ = ui.User{} // keep ui import alive } -// handleUISetupPost is a stub — full implementation lands in Task D2. func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "not implemented", stdhttp.StatusNotImplemented) + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + raw := r.PostForm.Get("token") + pw := r.PostForm.Get("password") + pw2 := r.PostForm.Get("password_confirm") + + if raw == "" { + s.renderSetupExpired(w, r) + return + } + if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 { + s.renderSetupForm(w, r, raw, "Passwords must match and be at least 12 characters.") + return + } + + tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw)) + if err != nil || tok.ExpiresAt.Before(time.Now().UTC()) { + s.renderSetupExpired(w, r) + return + } + u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID) + if err != nil { + s.renderSetupExpired(w, r) + return + } + + hash, err := auth.HashPassword(pw) + if err != nil { + slog.Error("setup: hash password", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil { + slog.Error("setup: set password", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.DeleteSetupToken(r.Context(), u.ID); err != nil { + slog.Warn("setup: delete token", "err", err) + // Non-fatal — password is set, audit will reflect it. + } + + // Drop a session cookie so the user lands authenticated on /. + rawSession, err := auth.NewToken() + if err != nil { + slog.Error("setup: session token", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + hashed := auth.HashToken(rawSession) + now := time.Now().UTC() + if err := s.deps.Store.CreateSession(r.Context(), store.Session{ + ID: hashed, UserID: u.ID, CreatedAt: now, + ExpiresAt: now.Add(8 * time.Hour), + }, hashed); err != nil { + slog.Error("setup: create session", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, Value: rawSession, + Path: "/", HttpOnly: true, + SameSite: stdhttp.SameSiteLaxMode, + Secure: s.deps.Cfg.CookieSecure, + Expires: now.Add(8 * time.Hour), + }) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, + Actor: "user", + Action: "user.setup_completed", + TargetKind: ptr("user"), + TargetID: &u.ID, + TS: now, + }) + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} + +// renderSetupForm re-renders the setup page with an inline error +// (e.g. password mismatch). 200 OK with the form intact so the user +// can correct without losing the token. +func (s *Server) renderSetupForm(w stdhttp.ResponseWriter, r *stdhttp.Request, token, errMsg string) { + view := s.baseView(r, nil) + view.Title = "Set your password · restic-manager" + username := "" + if tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(token)); err == nil { + if u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID); err == nil { + username = u.Username + } + } + view.Page = setupPage{Username: username, Token: token, Error: errMsg} + _ = s.deps.UI.Render(w, "setup", view) } diff --git a/internal/server/http/setup_test.go b/internal/server/http/setup_test.go index 739e782..ebb8a27 100644 --- a/internal/server/http/setup_test.go +++ b/internal/server/http/setup_test.go @@ -1,11 +1,14 @@ package http import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" "io" stdhttp "net/http" + "net/url" "strings" "testing" "time" @@ -88,3 +91,62 @@ func TestSetupGetExpiredToken(t *testing.T) { t.Errorf("status: got %d want 410", res.StatusCode) } } + +func TestSetupPostHappyPath(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + urlBase := ts.URL + now := time.Now().UTC() + + uid := ulid.Make().String() + _ = srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: uid, Username: "newbie", + PasswordHash: "", Role: store.RoleOperator, CreatedAt: now, + MustChangePassword: true, + }) + raw := "happy-token" + _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{ + UserID: uid, TokenHash: sha256Hex(raw), + ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }) + + form := url.Values{} + form.Set("token", raw) + form.Set("password", "averylongpassword") + form.Set("password_confirm", "averylongpassword") + req, _ := stdhttp.NewRequest("POST", urlBase+"/setup", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }} + res, err := c.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Errorf("status: got %d want 303", res.StatusCode) + } + if res.Header.Get("Location") != "/" { + t.Errorf("location: got %q want /", res.Header.Get("Location")) + } + + // Token is consumed. + if _, err := srv.deps.Store.LookupSetupToken(t.Context(), sha256Hex(raw)); err == nil { + t.Error("token should be deleted after consumption") + } + + // User can now log in via the normal route. + logBody, _ := json.Marshal(map[string]string{ + "username": "newbie", "password": "averylongpassword", + }) + loginRes, _ := stdhttp.Post(urlBase+"/api/auth/login", + "application/json", bytes.NewReader(logBody)) + defer loginRes.Body.Close() + if loginRes.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(loginRes.Body) + t.Errorf("login: %d %s", loginRes.StatusCode, body) + } +}