55242caf58
P1-28: Tailwind standalone CLI wired into the Makefile. `make tailwind` downloads the pinned v3.4.17 binary into bin/tailwindcss (gitignored), builds web/styles/input.css → web/static/css/styles.css. `make build` now runs the CSS pass first; `make tailwind-watch` for dev. Output is embedded in the binary via web.FS — single static binary, no Node. The CSS source carries every component class the v1 mockups defined (status dots, buttons, host row, log viewer, progress bar, fields, chips, snippet panel, empty state) so screens that land later can just reach for them. P1-23: html/template tree at web/templates with two layouts (base with chrome, chromeless for login + bootstrap), one nav partial, and two pages (dashboard placeholder, login). internal/server/ui parses the tree at startup; ui_handlers.go in the http package wires: GET / dashboard (303 → /login when unauthed) GET /login sign-in form POST /login consume form, mint session cookie, 303 → / POST /logout drop cookie, 303 → /login GET /static/* embedded Tailwind bundle The HTML login flow shares store/session logic with /api/auth/login via a new authenticateAndSession helper — same security guarantees, two surface representations (HTML form / JSON). Verified end-to-end: bootstrap → form-login → authed dashboard → sign-out → 303 cycle works in the browser; Tailwind output emits only the component classes referenced in the live templates (9.6kB minified). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
5.9 KiB
Go
211 lines
5.9 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
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/store"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "rm_session"
|
|
sessionTTL = 24 * time.Hour
|
|
bootstrapCookie = "rm_bootstrap_used"
|
|
)
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type loginResponse struct {
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
var req loginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
u, err := s.authenticateAndSession(w, r, req.Username, req.Password)
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "")
|
|
return
|
|
}
|
|
writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)})
|
|
}
|
|
|
|
// authenticateAndSession verifies credentials, mints a session cookie,
|
|
// records the login + audit, and returns the user. Any failure
|
|
// (unknown user, wrong password, db error) is collapsed into a single
|
|
// error — the caller decides how to surface it. Shared by JSON and
|
|
// HTML login flows.
|
|
func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Request,
|
|
username, password string,
|
|
) (*store.User, error) {
|
|
u, err := s.deps.Store.GetUserByUsername(r.Context(), username)
|
|
if err != nil {
|
|
// Same response for unknown user vs bad password — don't leak
|
|
// existence to a probing attacker.
|
|
return nil, errInvalidCredentials
|
|
}
|
|
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
|
|
return nil, errInvalidCredentials
|
|
}
|
|
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
sess := store.Session{
|
|
UserID: u.ID,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(sessionTTL),
|
|
IP: r.RemoteAddr,
|
|
UA: r.UserAgent(),
|
|
}
|
|
if err := s.deps.Store.CreateSession(r.Context(), sess, auth.HashToken(token)); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now)
|
|
|
|
stdhttp.SetCookie(w, &stdhttp.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.deps.Cfg.CookieSecure,
|
|
SameSite: stdhttp.SameSiteLaxMode,
|
|
Expires: sess.ExpiresAt,
|
|
})
|
|
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &u.ID,
|
|
Actor: "user",
|
|
Action: "auth.login",
|
|
TS: now,
|
|
})
|
|
return u, nil
|
|
}
|
|
|
|
// errInvalidCredentials is the sentinel returned by
|
|
// authenticateAndSession for any failure that maps to a 401 in HTTP.
|
|
var errInvalidCredentials = errAuth("invalid_credentials")
|
|
|
|
type errAuth string
|
|
|
|
func (e errAuth) Error() string { return string(e) }
|
|
|
|
func (s *Server) handleLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
|
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
|
}
|
|
stdhttp.SetCookie(w, &stdhttp.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: s.deps.Cfg.CookieSecure,
|
|
SameSite: stdhttp.SameSiteLaxMode,
|
|
})
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
}
|
|
|
|
type bootstrapRequest struct {
|
|
Token string `json:"token"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// handleBootstrap creates the first admin user. The endpoint accepts
|
|
// the one-time token printed in the server logs on first run, and is
|
|
// disabled the moment a user row exists.
|
|
func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
n, err := s.deps.Store.CountUsers(r.Context())
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
if n > 0 {
|
|
writeJSONError(w, stdhttp.StatusConflict, "already_initialised",
|
|
"a user already exists; bootstrap is disabled")
|
|
return
|
|
}
|
|
if s.deps.BootstrapToken == "" {
|
|
writeJSONError(w, stdhttp.StatusServiceUnavailable, "no_token",
|
|
"bootstrap token not configured")
|
|
return
|
|
}
|
|
|
|
var req bootstrapRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
// Constant-time compare keeps timing analysis off the table.
|
|
if subtle.ConstantTimeCompare([]byte(req.Token), []byte(s.deps.BootstrapToken)) != 1 {
|
|
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token", "")
|
|
return
|
|
}
|
|
if req.Username == "" || len(req.Password) < 12 {
|
|
writeJSONError(w, stdhttp.StatusBadRequest, "weak_password",
|
|
"password must be at least 12 characters")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(req.Password)
|
|
if err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
u := store.User{
|
|
ID: ulid.Make().String(),
|
|
Username: req.Username,
|
|
PasswordHash: hash,
|
|
Role: store.RoleAdmin,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
if err := s.deps.Store.CreateUser(r.Context(), u); err != nil {
|
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &u.ID,
|
|
Actor: "system",
|
|
Action: "auth.bootstrap",
|
|
TS: u.CreatedAt,
|
|
})
|
|
|
|
writeJSON(w, stdhttp.StatusCreated, loginResponse{
|
|
UserID: u.ID, Role: string(u.Role),
|
|
})
|
|
}
|
|
|
|
// ----- json helpers --------------------------------------------------
|
|
|
|
type jsonError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
func writeJSON(w stdhttp.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeJSONError(w stdhttp.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, jsonError{Code: code, Message: msg})
|
|
}
|