Files
2026-08-24 13:06:29 +05:30

260 lines
8.1 KiB
Go

// Command setpassword sets a user's password.
//
// It exists because migration 000001 left users.password_hash nullable and
// NULL, and the seeded demo user still has no password. Nothing in the seed
// fixture, the migrations or this repository contains, generates or defaults a
// password: a password enters the system here, typed by a person, and nowhere
// else.
//
// # prompt for the password, twice, with the input hidden
// cd go-api && go run ./cmd/setpassword -email demo@krow.app
// cd go-api && go run ./cmd/setpassword -id 9a1f...-uuid
//
// # non-interactive, for a provisioning script — the password arrives on
// # stdin, never in argv, so it does not reach `ps` or the shell history
// printf '%s' "$NEW_PASSWORD" | go run ./cmd/setpassword -email demo@krow.app -stdin
//
// There is deliberately no -password flag. A password in argv is visible to
// every process on the machine through `ps`, and lands in the shell history
// besides. stdin is the only non-interactive route.
//
// The password, the confirmation and the resulting hash are never printed,
// never logged and never written anywhere but the users.password_hash column,
// through a bind parameter.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/term"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
)
func main() {
if err := run(); err != nil {
// The error strings in this file name rules and identifiers only. No
// path here can carry the password into this line.
fmt.Fprintf(os.Stderr, "setpassword: %v\n", err)
os.Exit(1)
}
}
type target struct {
id string
email string
role string
hadHash bool
}
func run() error {
var (
email = flag.String("email", "", "the user's email address")
id = flag.String("id", "", "the user's UUID")
fromStdin = flag.Bool("stdin", false, "read the password from stdin instead of prompting")
)
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
"Usage: setpassword (-email <address> | -id <uuid>) [-stdin]\n\n"+
"Sets one user's password, hashed with argon2id. The password is never\n"+
"echoed, printed or logged, and there is no -password flag by design.\n\n")
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() > 0 {
// A bare argument is most likely someone typing the password after the
// command. Refuse loudly rather than ignoring it — and say nothing
// about what the argument was.
return errors.New("unexpected positional argument; pass -email or -id, and supply the password when prompted")
}
if (*email == "") == (*id == "") {
return errors.New("pass exactly one of -email or -id")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
database, err := db.Open(ctx, cfg.DB)
if err != nil {
return err
}
defer database.Close()
// Resolve and show the target BEFORE asking for a password, so nobody
// types a secret at a prompt that turns out to be pointed at the wrong
// user, or at no user at all.
t, err := resolve(ctx, database, *email, *id)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "database: %s\nuser: %s <%s>\nrole: %s\npassword: %s\n\n",
cfg.DB.Name, t.id, t.email, t.role, existingState(t.hadHash))
password, err := readPassword(*fromStdin)
if err != nil {
return err
}
// The plaintext lives in this slice and nowhere else. Wipe it as soon as
// the hash exists. Go's garbage collector may still have copied it, so
// this is a reduction in exposure rather than a guarantee — worth doing,
// not worth trusting.
defer wipe(password)
if err := auth.ValidatePassword(string(password)); err != nil {
return describePolicy(err)
}
hash, err := auth.HashPassword(string(password))
if err != nil {
return err
}
// Parameterized, and keyed by the UUID resolved above rather than by the
// string the operator typed. Neither the hash nor the password is ever
// interpolated into SQL.
const q = `UPDATE users SET password_hash = $2::text, updated_date = now() WHERE id = $1::uuid`
tag, err := database.Pool.Exec(ctx, q, t.id, hash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("expected to update exactly one user, updated %d", tag.RowsAffected())
}
// Confirms the identity and nothing about the secret: no hash, no length,
// no prefix.
fmt.Fprintf(os.Stderr, "password set for %s (%s)\n", t.email, t.id)
return nil
}
func existingState(had bool) string {
if had {
return "already set (it will be replaced)"
}
return "not set yet"
}
// resolve finds exactly one user by email or by id.
//
// Email lookup relies on the citext column, so it is case-insensitive, and on
// the global unique index added by migration 000004, so it cannot match two
// users in two organizations.
func resolve(ctx context.Context, database *db.DB, email, id string) (target, error) {
var (
t target
err error
)
if email != "" {
const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL
FROM users WHERE email = $1::citext`
err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(email)).
Scan(&t.id, &t.email, &t.role, &t.hadHash)
} else {
const q = `SELECT id::text, email::text, role, password_hash IS NOT NULL
FROM users WHERE id = $1::uuid`
err = database.Pool.QueryRow(ctx, q, strings.TrimSpace(id)).
Scan(&t.id, &t.email, &t.role, &t.hadHash)
}
if errors.Is(err, pgx.ErrNoRows) {
return t, errors.New("no such user")
}
if err != nil {
return t, fmt.Errorf("look up user: %w", err)
}
return t, nil
}
// readPassword collects the password without echoing it.
//
// Interactively it asks twice and compares, because a mistyped password that
// nobody can see is otherwise only discovered at the next login. With -stdin
// it reads the stream verbatim, minus one trailing newline, so
// `printf '%s' "$P" | setpassword -stdin` and a here-string both work.
func readPassword(fromStdin bool) ([]byte, error) {
if fromStdin {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("read password from stdin: %w", err)
}
return trimOneNewline(raw), nil
}
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
// Falling back to an echoing read here would print the password to the
// screen and into any transcript. Refuse and name the flag instead.
return nil, errors.New("stdin is not a terminal; re-run with -stdin to read the password from the pipe")
}
fmt.Fprint(os.Stderr, "New password: ")
first, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, fmt.Errorf("read password: %w", err)
}
fmt.Fprint(os.Stderr, "Confirm password: ")
second, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
wipe(first)
return nil, fmt.Errorf("read confirmation: %w", err)
}
defer wipe(second)
if string(first) != string(second) {
wipe(first)
return nil, errors.New("the two entries do not match")
}
return first, nil
}
// describePolicy turns a policy error into advice, still without quoting the
// password or revealing its length.
func describePolicy(err error) error {
switch {
case errors.Is(err, auth.ErrEmptyPassword):
return errors.New("the password is empty")
case errors.Is(err, auth.ErrPasswordTooShort):
return fmt.Errorf("the password is too short; it must be at least %d bytes", auth.MinPasswordLength)
case errors.Is(err, auth.ErrPasswordTooLong):
return fmt.Errorf("the password is too long; the maximum is %d bytes", auth.MaxPasswordLength)
}
return err
}
// trimOneNewline removes a single trailing "\n" or "\r\n", and only one: a
// password may legitimately end in whitespace, so this strips the line
// terminator a shell adds and nothing more.
func trimOneNewline(b []byte) []byte {
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
if n := len(b); n > 0 && b[n-1] == '\r' {
b = b[:n-1]
}
}
return b
}
func wipe(b []byte) {
for i := range b {
b[i] = 0
}
}