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()
|
||||
|
|
@ -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 <version> Target version for rollback")
|
||||
fmt.Fprintln(os.Stderr, " -db-url <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 <version> Target version for rollback")
|
||||
fmt.Fprintln(os.Stderr, " -db-url <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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue