first commit
This commit is contained in:
259
go-api/cmd/setpassword/main.go
Normal file
259
go-api/cmd/setpassword/main.go
Normal file
@@ -0,0 +1,259 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user