release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
`minstrel admin reset-password` copied the new login password into subsonic_password, which is stored in plain text because Subsonic t/s sign-in needs it. Every account recovered through the CLI had its login password readable in the database, and changing the password later left the copy behind. - reset-password now changes only password_hash. - Migration 0064 clears every subsonic_password, removing the copies. - Settings gets a Subsonic password card: the server generates a random password, shows it once, and it can be regenerated or turned off (GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather than user-chosen so it can never be a reused password. - docs/security.md describes the separate password instead of the known issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
|
)
|
|
|
|
// runMigrate applies the embedded migrations and exits. The server also
|
|
// auto-migrates on startup (main.go); this standalone path exists for CI
|
|
// (provision a fresh DB before the integration suite) and operators who
|
|
// want to migrate without starting the server.
|
|
func runMigrate(args []string) error {
|
|
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
|
|
if err != nil {
|
|
return fmt.Errorf("init logger: %w", err)
|
|
}
|
|
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
fmt.Println("minstrel: migrations applied")
|
|
return nil
|
|
}
|
|
|
|
// runAdmin dispatches `minstrel admin <subcommand>`.
|
|
func runAdmin(args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
|
|
}
|
|
switch args[0] {
|
|
case "reset-password":
|
|
return adminResetPassword(args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown admin subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// adminResetPassword resets a user's login password (password_hash). It
|
|
// recovers a locked-out operator when the bootstrap password was missed or
|
|
// the DB volume was recreated (Fable #321) without DB surgery.
|
|
//
|
|
// It deliberately leaves subsonic_password alone. That column is stored in
|
|
// plain text, and it used to receive the new login password here, so any
|
|
// account recovered this way had its login password readable in the database
|
|
// (#5026). The Subsonic password is generated separately in Settings.
|
|
func adminResetPassword(args []string) error {
|
|
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
|
username := fs.String("user", "admin", "username to reset")
|
|
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
if cfg.Database.URL == "" {
|
|
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pool, err := db.Open(ctx, cfg.Database.URL)
|
|
if err != nil {
|
|
return fmt.Errorf("open db: %w", err)
|
|
}
|
|
defer pool.Close()
|
|
q := dbq.New(pool)
|
|
|
|
user, err := q.GetUserByUsername(ctx, *username)
|
|
if err != nil {
|
|
return fmt.Errorf("look up user %q: %w", *username, err)
|
|
}
|
|
|
|
pw := *password
|
|
generated := false
|
|
if pw == "" {
|
|
b := make([]byte, 18)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return fmt.Errorf("generate password: %w", err)
|
|
}
|
|
pw = base64.RawURLEncoding.EncodeToString(b)
|
|
generated = true
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
|
|
ID: user.ID,
|
|
PasswordHash: string(hash),
|
|
}); err != nil {
|
|
return fmt.Errorf("update password_hash: %w", err)
|
|
}
|
|
|
|
if generated {
|
|
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
|
} else {
|
|
fmt.Printf("minstrel: password for %q reset.\n", *username)
|
|
}
|
|
return nil
|
|
}
|