diff --git a/cmd/server/main.go b/cmd/server/main.go index 0545666..1a7edc0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,8 +13,12 @@ import ( "time" _ "github.com/jackc/pgx/v5/stdlib" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" "git.soup.land/soup/sxgo/ssr" + "lookbook/internal/data/admin" + "lookbook/internal/data/session" "lookbook/internal/handlers" "lookbook/internal/middleware" "lookbook/internal/migrations" @@ -71,6 +75,8 @@ func main() { switch mode { case "web": runWebServer(dbURL, logger) + case "set-password": + runSetPassword(dbURL, logger) default: logger.Error("unknown mode", slog.String("mode", mode)) printUsage() @@ -80,13 +86,14 @@ func main() { func printUsage() { fmt.Fprintln(os.Stderr, "Usage:") - fmt.Fprintln(os.Stderr, " lookbook web - Run web server") + fmt.Fprintln(os.Stderr, " lookbook web - Run web server") + fmt.Fprintln(os.Stderr, " lookbook set-password - Set/reset admin password") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Flags:") - fmt.Fprintln(os.Stderr, " -migrate Run database migrations") - fmt.Fprintln(os.Stderr, " -rollback Roll back migrations") - fmt.Fprintln(os.Stderr, " -to Target version for rollback") - fmt.Fprintln(os.Stderr, " -db-url Database connection URL") + fmt.Fprintln(os.Stderr, " -migrate Run database migrations") + fmt.Fprintln(os.Stderr, " -rollback Roll back migrations") + fmt.Fprintln(os.Stderr, " -to Target version for rollback") + fmt.Fprintln(os.Stderr, " -db-url Database connection URL") } func runWebServer(dbURL string, logger *slog.Logger) { @@ -134,6 +141,7 @@ func runWebServer(dbURL string, logger *slog.Logger) { router.Handle("POST /api/auth/login", handlers.HandleLogin) router.Handle("POST /api/auth/logout", handlers.HandleLogout) router.Handle("GET /api/auth/status", handlers.HandleAuthStatus) + router.Handle("POST /api/auth/password", handlers.HandleChangePassword) // Items API router.Handle("GET /api/items", handlers.HandleListItems) @@ -190,3 +198,71 @@ func waitForShutdown(server *http.Server, logger *slog.Logger) { logger.Error("graceful shutdown failed", slog.Any("err", err)) } } + +func runSetPassword(dbURL string, logger *slog.Logger) { + fmt.Print("Enter new password: ") + password, err := readPassword() + if err != nil { + logger.Error("failed to read password", slog.Any("err", err)) + os.Exit(1) + } + + if password == "" { + logger.Error("password cannot be empty") + os.Exit(1) + } + + fmt.Print("Confirm password: ") + confirm, err := readPassword() + if err != nil { + logger.Error("failed to read confirmation", slog.Any("err", err)) + os.Exit(1) + } + + if password != confirm { + logger.Error("passwords do not match") + os.Exit(1) + } + + db, err := sql.Open("pgx", dbURL) + if err != nil { + logger.Error("failed to open database", slog.Any("err", err)) + os.Exit(1) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logger.Error("failed to hash password", slog.Any("err", err)) + os.Exit(1) + } + + if err := admin.QSetPassword(ctx, db, hash); err != nil { + logger.Error("failed to set password", slog.Any("err", err)) + os.Exit(1) + } + + // Invalidate all sessions + if err := session.QDeleteAll(ctx, db); err != nil { + logger.Error("failed to clear sessions", slog.Any("err", err)) + os.Exit(1) + } + + logger.Info("password updated, all sessions invalidated") +} + +func readPassword() (string, error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + bytes, err := term.ReadPassword(fd) + fmt.Println() // newline after password input + return string(bytes), err + } + // Fallback for non-terminal (piped input) + var password string + _, err := fmt.Scanln(&password) + return password, err +} diff --git a/go.mod b/go.mod index f45b382..b7363d9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module lookbook -go 1.23.0 +go 1.24.0 require ( git.soup.land/soup/sxgo v0.1.1 @@ -19,5 +19,7 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index d938dbb..894ed68 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,10 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/data/session/queries.go b/internal/data/session/queries.go index 15386fc..512d4e4 100644 --- a/internal/data/session/queries.go +++ b/internal/data/session/queries.go @@ -71,3 +71,17 @@ func QDeleteExpired(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, query) return err } + +// QDeleteAllExcept deletes all sessions except the one with the given session ID. +func QDeleteAllExcept(ctx context.Context, db *sql.DB, exceptSessionID string) error { + query := `DELETE FROM session WHERE session_id != $1` + _, err := db.ExecContext(ctx, query, exceptSessionID) + return err +} + +// QDeleteAll deletes all sessions. +func QDeleteAll(ctx context.Context, db *sql.DB) error { + query := `DELETE FROM session` + _, err := db.ExecContext(ctx, query) + return err +} diff --git a/internal/handlers/api_auth.go b/internal/handlers/api_auth.go index 9bc6555..4de410b 100644 --- a/internal/handlers/api_auth.go +++ b/internal/handlers/api_auth.go @@ -25,6 +25,11 @@ type loginResponse struct { Error string `json:"error,omitempty"` } +type changePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + // HandleLogin handles POST /api/auth/login // If no password is set, it sets the password. Otherwise, it verifies the password. func HandleLogin(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { @@ -126,6 +131,55 @@ func HandleAuthStatus(rc *RequestContext, w http.ResponseWriter, r *http.Request }) } +// HandleChangePassword handles POST /api/auth/password +func HandleChangePassword(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { + if !rc.RequireAdmin(w) { + return nil + } + + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + if req.CurrentPassword == "" || req.NewPassword == "" { + return writeJSON(w, http.StatusBadRequest, map[string]string{"error": "both passwords required"}) + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Get current password hash + adm, err := admin.QGet(ctx, rc.DB) + if err != nil { + return err + } + + // Verify current password + if err := bcrypt.CompareHashAndPassword(adm.PasswordHash, []byte(req.CurrentPassword)); err != nil { + return writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid current password"}) + } + + // Hash new password + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + // Update password + if err := admin.QSetPassword(ctx, rc.DB, hash); err != nil { + return err + } + + // Invalidate all other sessions (keep current session) + cookie, _ := r.Cookie("session_id") + if cookie != nil { + session.QDeleteAllExcept(ctx, rc.DB, cookie.Value) + } + + return writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + func generateSessionID() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/static/js/app.js b/internal/static/js/app.js index f11bc49..5ed0bb7 100644 --- a/internal/static/js/app.js +++ b/internal/static/js/app.js @@ -36,6 +36,28 @@ window.logout = async () => { } }; +window.changePassword = async (currentPassword, newPassword) => { + if (!currentPassword || !newPassword) { + console.error('Usage: changePassword("current-password", "new-password")'); + return; + } + try { + const res = await fetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + const data = await res.json(); + if (res.ok) { + console.log("Password changed successfully!"); + } else { + console.error(data.error || "Failed to change password"); + } + } catch (err) { + console.error("Password change error:", err); + } +}; + // Modal functions function showAddModal() { document.getElementById("add-modal").classList.add("active");