Add password change functionality

- Add POST /api/auth/password API endpoint
  - Requires authentication and current password verification
  - Invalidates all other sessions after password change
  - Keeps current session active

- Add window.changePassword() console function
  - Matches existing login flow pattern
  - Usage: changePassword("current", "new")

- Add 'lookbook set-password' CLI command
  - Interactive password reset (no current password required)
  - Useful for recovery scenarios
  - Invalidates all sessions

- Add session.QDeleteAllExcept() and session.QDeleteAll()
  - Support for invalidating sessions after password change
This commit is contained in:
soup 2026-01-17 22:28:13 -05:00
parent 5b472de209
commit 523831cb8d
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
6 changed files with 178 additions and 6 deletions

View file

@ -13,8 +13,12 @@ import (
"time" "time"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
"git.soup.land/soup/sxgo/ssr" "git.soup.land/soup/sxgo/ssr"
"lookbook/internal/data/admin"
"lookbook/internal/data/session"
"lookbook/internal/handlers" "lookbook/internal/handlers"
"lookbook/internal/middleware" "lookbook/internal/middleware"
"lookbook/internal/migrations" "lookbook/internal/migrations"
@ -71,6 +75,8 @@ func main() {
switch mode { switch mode {
case "web": case "web":
runWebServer(dbURL, logger) runWebServer(dbURL, logger)
case "set-password":
runSetPassword(dbURL, logger)
default: default:
logger.Error("unknown mode", slog.String("mode", mode)) logger.Error("unknown mode", slog.String("mode", mode))
printUsage() printUsage()
@ -80,13 +86,14 @@ func main() {
func printUsage() { func printUsage() {
fmt.Fprintln(os.Stderr, "Usage:") 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, "")
fmt.Fprintln(os.Stderr, "Flags:") fmt.Fprintln(os.Stderr, "Flags:")
fmt.Fprintln(os.Stderr, " -migrate Run database migrations") fmt.Fprintln(os.Stderr, " -migrate Run database migrations")
fmt.Fprintln(os.Stderr, " -rollback Roll back migrations") fmt.Fprintln(os.Stderr, " -rollback Roll back migrations")
fmt.Fprintln(os.Stderr, " -to <version> Target version for rollback") fmt.Fprintln(os.Stderr, " -to <version> Target version for rollback")
fmt.Fprintln(os.Stderr, " -db-url <url> Database connection URL") fmt.Fprintln(os.Stderr, " -db-url <url> Database connection URL")
} }
func runWebServer(dbURL string, logger *slog.Logger) { 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/login", handlers.HandleLogin)
router.Handle("POST /api/auth/logout", handlers.HandleLogout) router.Handle("POST /api/auth/logout", handlers.HandleLogout)
router.Handle("GET /api/auth/status", handlers.HandleAuthStatus) router.Handle("GET /api/auth/status", handlers.HandleAuthStatus)
router.Handle("POST /api/auth/password", handlers.HandleChangePassword)
// Items API // Items API
router.Handle("GET /api/items", handlers.HandleListItems) 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)) 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
}

4
go.mod
View file

@ -1,6 +1,6 @@
module lookbook module lookbook
go 1.23.0 go 1.24.0
require ( require (
git.soup.land/soup/sxgo v0.1.1 git.soup.land/soup/sxgo v0.1.1
@ -19,5 +19,7 @@ require (
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.16.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 golang.org/x/text v0.27.0 // indirect
) )

4
go.sum
View file

@ -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/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 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -71,3 +71,17 @@ func QDeleteExpired(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, query) _, err := db.ExecContext(ctx, query)
return err 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
}

View file

@ -25,6 +25,11 @@ type loginResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type changePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// HandleLogin handles POST /api/auth/login // HandleLogin handles POST /api/auth/login
// If no password is set, it sets the password. Otherwise, it verifies the password. // 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 { 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) { func generateSessionID() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {

View file

@ -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 // Modal functions
function showAddModal() { function showAddModal() {
document.getElementById("add-modal").classList.add("active"); document.getElementById("add-modal").classList.add("active");