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:
parent
5b472de209
commit
523831cb8d
6 changed files with 178 additions and 6 deletions
|
|
@ -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()
|
||||
|
|
@ -81,6 +87,7 @@ func main() {
|
|||
func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage:")
|
||||
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")
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue