260 lines
8.1 KiB
Go
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
|
|
}
|
|
}
|