Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
All checks were successful
check / check (push) Successful in 3m25s
All checks were successful
check / check (push) Successful in 3m25s
The admin bootstrap password was printed once, as one line among roughly 45 fx lines, and under docker run -d went to container logs subject to rotation. There was no reset path at all -- no subcommand, no forgot-password flow, no env override -- so recovery meant hand-deleting the users row from webhooker.db, which was documented nowhere. Adds webhooker resetpw [-generate] <username>. The password is read from stdin or generated with the existing crypto/rand helper, never taken from argv where /proc would publish it. It reuses the existing Argon2id hashing rather than reimplementing the parameters, and writes a single UPDATE only after the hash is complete, so no failure can leave an account with no usable password. An unknown username is a hard error and never creates an account. It refuses to run against a DATA_DIR held by a live instance, via the exclusive lock from #201. DATA_DIR and webhooker.db are checked to exist before the lock is acquired, so a mistyped path creates nothing -- neither a directory tree nor a stray lock file. The bootstrap password now appears exactly once, in a distinct banner written straight to a caller-named writer rather than as an fx log line.
This commit was merged in pull request #239.
This commit is contained in:
472
internal/resetpw/resetpw.go
Normal file
472
internal/resetpw/resetpw.go
Normal file
@@ -0,0 +1,472 @@
|
||||
// Package resetpw implements the `webhooker resetpw` subcommand,
|
||||
// which sets an existing account's password from the command line.
|
||||
//
|
||||
// It exists because the bootstrap password is shown exactly once. If it
|
||||
// is lost — the boot's output rotated away, the terminal closed — the
|
||||
// deployment has no other way in: there is no second account, no
|
||||
// forgot-password flow, and no environment override. The only recovery
|
||||
// before this command was deleting the row from webhooker.db with a
|
||||
// SQLite client so the next start would re-seed.
|
||||
//
|
||||
// It operates on a stopped deployment only. The password is read from
|
||||
// standard input or generated, never taken from argv, and it reuses the
|
||||
// service's own Argon2id hashing rather than reimplementing it.
|
||||
package resetpw
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/banner"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
)
|
||||
|
||||
// Name is the subcommand's name on the command line.
|
||||
const Name = "resetpw"
|
||||
|
||||
const (
|
||||
// generatedPasswordLen is the length of a -generate password. It
|
||||
// is longer than the 16 characters the first boot generates: this
|
||||
// one is typed or pasted once by an operator recovering a
|
||||
// deployment, not carried around.
|
||||
generatedPasswordLen = 24
|
||||
|
||||
// minPasswordLen is the shortest password accepted on standard
|
||||
// input. It is a floor against a stray keystroke or a truncated
|
||||
// pipe silently becoming the account's credential, not a password
|
||||
// policy.
|
||||
minPasswordLen = 8
|
||||
)
|
||||
|
||||
// Exit statuses. Usage errors are distinguished from operational ones
|
||||
// so that a script can tell "you called it wrong" from "it refused".
|
||||
const (
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
// Sentinel errors, exported so a test can assert on the refusal rather
|
||||
// than on the wording of a message.
|
||||
var (
|
||||
// ErrNoDataDir reports that DATA_DIR names nothing, or names
|
||||
// something that is not a directory.
|
||||
ErrNoDataDir = errors.New("no data directory")
|
||||
|
||||
// ErrNoDatabase reports that the data directory holds no main
|
||||
// database, so there is no deployment to reset a password in.
|
||||
ErrNoDatabase = errors.New("no webhooker database")
|
||||
|
||||
// ErrLiveInstance reports that a running webhooker holds the data
|
||||
// directory.
|
||||
ErrLiveInstance = errors.New(
|
||||
"data directory is held by a running webhooker",
|
||||
)
|
||||
|
||||
// ErrNoSuchUser reports that the named account does not exist.
|
||||
ErrNoSuchUser = errors.New("no such user")
|
||||
|
||||
// ErrEmptyPassword reports that standard input carried nothing.
|
||||
ErrEmptyPassword = errors.New("empty password")
|
||||
|
||||
// ErrPasswordTooShort reports a password below minPasswordLen.
|
||||
ErrPasswordTooShort = errors.New("password too short")
|
||||
|
||||
// ErrNotUpdated reports that the update matched no row, which
|
||||
// means the account disappeared between the lookup and the write.
|
||||
ErrNotUpdated = errors.New("password was not updated")
|
||||
)
|
||||
|
||||
// usage describes the subcommand. It is written to the same stream as
|
||||
// the error that provoked it.
|
||||
func usage(w io.Writer, flags *flag.FlagSet) {
|
||||
_, _ = fmt.Fprintf(w, `usage: webhooker %s [-generate] <username>
|
||||
|
||||
Set an existing account's password. The deployment must be stopped:
|
||||
webhooker %s takes the same exclusive DATA_DIR lock the server does
|
||||
and refuses to run while a live instance holds it.
|
||||
|
||||
The password is read as one line from standard input, or generated
|
||||
with -generate. It is never taken as a command-line argument, which
|
||||
on Linux would publish it in /proc to every account on the host.
|
||||
|
||||
DATA_DIR selects the deployment exactly as it does for the server
|
||||
(default %s). The directory and its database must already exist;
|
||||
nothing is created.
|
||||
|
||||
Flags:
|
||||
`, Name, Name, config.DefaultDataDir)
|
||||
|
||||
flags.PrintDefaults()
|
||||
}
|
||||
|
||||
// Run executes the subcommand and returns the process exit status.
|
||||
func Run(
|
||||
args []string,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
) int {
|
||||
flags := flag.NewFlagSet("webhooker "+Name, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
|
||||
generate := flags.Bool(
|
||||
"generate", false,
|
||||
"generate a random password instead of reading one from "+
|
||||
"standard input, and print it",
|
||||
)
|
||||
|
||||
flags.Usage = func() { usage(stderr, flags) }
|
||||
|
||||
err := flags.Parse(args)
|
||||
if err != nil {
|
||||
// flag has already reported the error and printed the usage.
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if flags.NArg() != 1 {
|
||||
_, _ = fmt.Fprintf(
|
||||
stderr,
|
||||
"webhooker %s: exactly one username is required\n",
|
||||
Name,
|
||||
)
|
||||
usage(stderr, flags)
|
||||
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
err = reset(flags.Arg(0), *generate, stdin, stdout, stderr)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "webhooker %s: %v\n", Name, err)
|
||||
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// reset performs the whole operation against the configured data
|
||||
// directory.
|
||||
func reset(
|
||||
username string,
|
||||
generate bool,
|
||||
stdin io.Reader,
|
||||
stdout, stderr io.Writer,
|
||||
) error {
|
||||
dir := config.DataDir()
|
||||
|
||||
err := checkDataDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lock, err := acquire(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The kernel drops the lock when this process exits, whatever
|
||||
// happens below; releasing explicitly is what makes a long-running
|
||||
// caller — a test — see it freed. A release error tells the
|
||||
// operator nothing they can act on.
|
||||
defer func() { _ = lock.Release() }()
|
||||
|
||||
db, err := database.Open(dir, cliLogger(stderr))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err := setPassword(db, username, generate, stdin, stderr)
|
||||
|
||||
closeErr := db.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("closing the database: %w", closeErr)
|
||||
}
|
||||
|
||||
return report(stdout, stderr, dir, username, password, generate)
|
||||
}
|
||||
|
||||
// report tells the operator what happened.
|
||||
//
|
||||
// A generated password is printed in the same banner the first boot
|
||||
// uses, because this is the only time it is ever shown. A password the
|
||||
// operator supplied is not echoed back: they already have it, and
|
||||
// writing it to standard output a second time would put it in another
|
||||
// log for no gain.
|
||||
func report(
|
||||
stdout, stderr io.Writer,
|
||||
dir, username, password string,
|
||||
generate bool,
|
||||
) error {
|
||||
if generate {
|
||||
write := func(w io.Writer) error {
|
||||
return banner.Credentials(
|
||||
w,
|
||||
"WEBHOOKER PASSWORD RESET: this account's new "+
|
||||
"password is",
|
||||
username,
|
||||
password,
|
||||
"Save this password now: it is shown only here.\n"+
|
||||
"The database stores only its Argon2id hash.",
|
||||
)
|
||||
}
|
||||
|
||||
err := write(stdout)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The password is already stored. Standard output failing
|
||||
// here is the difference between a recovered deployment and
|
||||
// one locked out behind a password nobody has ever seen, so
|
||||
// try the other stream before giving up.
|
||||
if write(stderr) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(
|
||||
stdout,
|
||||
"password updated for user %q in %s\n", username, dir,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing the result: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquire takes the DATA_DIR lock, translating the contended case into
|
||||
// the refusal this command owes the operator.
|
||||
//
|
||||
// Resetting a password underneath a live instance would not corrupt
|
||||
// anything, but the running process keeps serving every session that
|
||||
// authenticated with the old one, so the operator would be told the
|
||||
// password changed while the deployment still behaved as though it had
|
||||
// not. Refusing is also what the lock is for.
|
||||
func acquire(dir string) (*datadir.Lock, error) {
|
||||
lock, err := datadir.Acquire(dir)
|
||||
if err == nil {
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, datadir.ErrLocked) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s. Stop it and run this again: a password reset "+
|
||||
"does not reach a running process, whose existing "+
|
||||
"sessions stay authenticated",
|
||||
ErrLiveInstance, dir,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// checkDataDir refuses to act on a path that does not already hold a
|
||||
// deployment.
|
||||
//
|
||||
// This runs before datadir.Acquire on purpose. Acquire calls
|
||||
// os.MkdirAll, so a mistyped DATA_DIR would otherwise be built out —
|
||||
// the directory tree, the lock file, and then an empty migrated
|
||||
// database — and the command would report success against a deployment
|
||||
// that does not exist while the real one stayed locked out. Nothing
|
||||
// here creates anything.
|
||||
func checkDataDir(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return fmt.Errorf(
|
||||
"%w: %s (DATA_DIR). This acts on an existing "+
|
||||
"deployment and creates nothing",
|
||||
ErrNoDataDir, dir,
|
||||
)
|
||||
case err != nil:
|
||||
return fmt.Errorf("checking data directory %s: %w", dir, err)
|
||||
case !info.IsDir():
|
||||
return fmt.Errorf(
|
||||
"%w: %s (DATA_DIR) is not a directory", ErrNoDataDir, dir,
|
||||
)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dir, database.MainDBFileName)
|
||||
|
||||
_, err = os.Stat(dbPath)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return fmt.Errorf(
|
||||
"%w: %s does not exist. The admin account is created by "+
|
||||
"the first server start",
|
||||
ErrNoDatabase, dbPath,
|
||||
)
|
||||
case err != nil:
|
||||
return fmt.Errorf("checking %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setPassword looks the account up, obtains the new password, and
|
||||
// writes its hash.
|
||||
//
|
||||
// The order matters: the account is resolved before an operator is
|
||||
// asked to type anything, and the hash is computed in full before the
|
||||
// single UPDATE that stores it. A failure at any step therefore leaves
|
||||
// the stored credential exactly as it was — there is no half-written
|
||||
// state to recover from.
|
||||
func setPassword(
|
||||
db *database.Database,
|
||||
username string,
|
||||
generate bool,
|
||||
stdin io.Reader,
|
||||
stderr io.Writer,
|
||||
) (string, error) {
|
||||
var user database.User
|
||||
|
||||
err := db.DB().Where("username = ?", username).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %q. This changes an existing account's password "+
|
||||
"and never creates an account",
|
||||
ErrNoSuchUser, username,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up user %q: %w", username, err)
|
||||
}
|
||||
|
||||
password, err := newPassword(generate, stdin, stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash, err := database.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hashing the new password: %w", err)
|
||||
}
|
||||
|
||||
result := db.DB().Model(&database.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Update("password", hash)
|
||||
if result.Error != nil {
|
||||
return "", fmt.Errorf(
|
||||
"updating user %q: %w", username, result.Error,
|
||||
)
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %q matched %d rows",
|
||||
ErrNotUpdated, username, result.RowsAffected,
|
||||
)
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// newPassword returns the password to store: generated, or read from
|
||||
// standard input.
|
||||
func newPassword(
|
||||
generate bool,
|
||||
stdin io.Reader,
|
||||
stderr io.Writer,
|
||||
) (string, error) {
|
||||
if generate {
|
||||
password, err := database.GenerateRandomPassword(
|
||||
generatedPasswordLen,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generating a password: %w", err)
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
return readPassword(stdin, stderr)
|
||||
}
|
||||
|
||||
// readPassword reads the new password as one line from standard input,
|
||||
// minus its line ending.
|
||||
//
|
||||
// Standard input rather than an argument: on Linux argv is readable
|
||||
// through /proc by every account on the host for as long as the process
|
||||
// lives, and a password typed as an argument lands in shell history
|
||||
// besides.
|
||||
//
|
||||
// When standard input is a terminal the input is echoed — no attempt is
|
||||
// made to put the terminal into no-echo mode — so the prompt says so
|
||||
// rather than letting an operator assume otherwise.
|
||||
func readPassword(stdin io.Reader, stderr io.Writer) (string, error) {
|
||||
if f, ok := stdin.(*os.File); ok && isTerminal(f) {
|
||||
_, _ = fmt.Fprintf(
|
||||
stderr,
|
||||
"New password (echoed as you type), then Enter: ",
|
||||
)
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(stdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", fmt.Errorf(
|
||||
"reading the password from standard input: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
password := strings.TrimRight(line, "\r\n")
|
||||
|
||||
if password == "" {
|
||||
return "", fmt.Errorf(
|
||||
"%w: standard input carried no password. Pipe one in, "+
|
||||
"or pass -generate",
|
||||
ErrEmptyPassword,
|
||||
)
|
||||
}
|
||||
|
||||
if len(password) < minPasswordLen {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %d bytes, minimum %d",
|
||||
ErrPasswordTooShort, len(password), minPasswordLen,
|
||||
)
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// isTerminal reports whether f is a character device, which is as much
|
||||
// as this needs to know to decide whether to prompt.
|
||||
func isTerminal(f *os.File) bool {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// cliLogger builds the logger the database layer writes through while
|
||||
// this command runs. It is deliberately quiet: connecting and migrating
|
||||
// are steps the operator did not ask about, and the one thing they need
|
||||
// to see is the outcome on standard output.
|
||||
func cliLogger(stderr io.Writer) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelWarn,
|
||||
}))
|
||||
}
|
||||
443
internal/resetpw/resetpw_test.go
Normal file
443
internal/resetpw/resetpw_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package resetpw_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
// operatorUser is the account these tests recover.
|
||||
operatorUser = "admin"
|
||||
|
||||
// newPassword is what the operator sets it to.
|
||||
newPassword = "correct horse battery staple"
|
||||
|
||||
// placeholderHash stands in for the stored credential nobody
|
||||
// knows any more — the lost bootstrap password. Nothing here
|
||||
// verifies against it; what matters is whether it is still there
|
||||
// after a refusal, or replaced after a reset.
|
||||
placeholderHash = "$argon2id$lost"
|
||||
|
||||
// exitOK and exitFailure are the statuses Run returns.
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
)
|
||||
|
||||
// testLogger is quiet unless something goes wrong.
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelWarn,
|
||||
}))
|
||||
}
|
||||
|
||||
// newDeployment builds a data directory holding a migrated database
|
||||
// with one account whose password is unknown, and points DATA_DIR at
|
||||
// it. It deliberately does not boot the server graph: seeding through
|
||||
// it would spend an Argon2id hash on a password no test can use.
|
||||
func newDeployment(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.DB().Create(&database.User{
|
||||
Username: operatorUser,
|
||||
Password: placeholderHash,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// storedHash reads the account's stored credential back.
|
||||
func storedHash(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
var user database.User
|
||||
|
||||
require.NoError(t, db.DB().
|
||||
Where("username = ?", operatorUser).
|
||||
First(&user).Error)
|
||||
|
||||
return user.Password
|
||||
}
|
||||
|
||||
// bannerPassword returns the plaintext a credentials banner printed.
|
||||
func bannerPassword(t *testing.T, out string) string {
|
||||
t.Helper()
|
||||
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
_, value, found := strings.Cut(line, "password: ")
|
||||
if found {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no password line in:\n%s", out)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// run drives the subcommand with the given standard input and returns
|
||||
// its status alongside what it wrote.
|
||||
func run(
|
||||
t *testing.T, stdin string, args ...string,
|
||||
) (int, string, string) {
|
||||
t.Helper()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
args, strings.NewReader(stdin), &stdout, &stderr,
|
||||
)
|
||||
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
type noopEvictor struct{}
|
||||
|
||||
func (n *noopEvictor) EvictWebhook(string) {}
|
||||
|
||||
// newServerApp starts the real login path against dir: the handlers,
|
||||
// the middleware that bounds password verification, the session store
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
) (*handlers.Handlers, *fxtest.App) {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{DataDir: dir}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||
middleware.New,
|
||||
handlers.New,
|
||||
),
|
||||
fx.Populate(&h),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
return h, app
|
||||
}
|
||||
|
||||
// submitLogin drives one login form POST through the real handler.
|
||||
func submitLogin(
|
||||
h *handlers.Handlers, username, password string,
|
||||
) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/pages/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
req.Header.Set(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
)
|
||||
req.RemoteAddr = "10.0.0.1:44444"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleLoginSubmit().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// TestResetThenLogin is the definition of done of
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/208: an operator who lost
|
||||
// the one-time bootstrap password sets a new one from the command line
|
||||
// and logs in with it.
|
||||
//
|
||||
// The login is the real one — the form POST through
|
||||
// handlers.HandleLoginSubmit, which looks the account up and verifies
|
||||
// the stored Argon2id hash — so a reset that wrote a hash the login
|
||||
// path cannot verify fails here rather than passing a re-implementation
|
||||
// of the check.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestResetThenLogin(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, stdout, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
require.Equal(t, exitOK, code, "stderr: %s", stderr)
|
||||
|
||||
assert.NotContains(
|
||||
t, stdout, newPassword,
|
||||
"a password the operator supplied must not be echoed back",
|
||||
)
|
||||
assert.Contains(t, stdout, operatorUser)
|
||||
|
||||
h, app := newServerApp(t, dir)
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
got := submitLogin(h, operatorUser, newPassword)
|
||||
require.Equal(
|
||||
t, http.StatusSeeOther, got.Code,
|
||||
"the new password must log in",
|
||||
)
|
||||
|
||||
got = submitLogin(h, operatorUser, "not-"+newPassword)
|
||||
require.NotEqual(
|
||||
t, http.StatusSeeOther, got.Code,
|
||||
"the reset must not make every password work",
|
||||
)
|
||||
}
|
||||
|
||||
// TestGeneratedPasswordIsPrintedAndWorks covers -generate, the mode an
|
||||
// operator recovering a deployment actually reaches for. The generated
|
||||
// password is shown once, in the banner, and must be the one stored.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestGeneratedPasswordIsPrintedAndWorks(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, stdout, stderr := run(t, "", "-generate", operatorUser)
|
||||
require.Equal(t, exitOK, code, "stderr: %s", stderr)
|
||||
|
||||
require.Contains(
|
||||
t, stdout, strings.Repeat("=", 20),
|
||||
"a generated password must be printed as a banner",
|
||||
)
|
||||
|
||||
password := bannerPassword(t, stdout)
|
||||
|
||||
ok, err := database.VerifyPassword(password, storedHash(t, dir))
|
||||
require.NoError(t, err)
|
||||
assert.True(
|
||||
t, ok, "the printed password must open the account",
|
||||
)
|
||||
}
|
||||
|
||||
// failingWriter is a standard output that cannot be written to.
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, assert.AnError
|
||||
}
|
||||
|
||||
// TestGeneratedPasswordSurvivesAFailedStdout covers the one outcome
|
||||
// worse than an error: the password is already stored, so a banner
|
||||
// that cannot be written to standard output must go to standard error
|
||||
// rather than leaving the deployment behind a password nobody has seen.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestGeneratedPasswordSurvivesAFailedStdout(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
[]string{"-generate", operatorUser},
|
||||
strings.NewReader(""), failingWriter{}, &stderr,
|
||||
)
|
||||
|
||||
require.Equal(t, exitOK, code)
|
||||
|
||||
password := bannerPassword(t, stderr.String())
|
||||
|
||||
ok, err := database.VerifyPassword(password, storedHash(t, dir))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
// TestRefusesLiveInstance pins the refusal the issue requires. The
|
||||
// running deployment keeps serving the sessions that authenticated
|
||||
// with the old password, so a reset underneath it would report a
|
||||
// change the service does not honour.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestRefusesLiveInstance(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
lock, err := datadir.Acquire(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, lock.Release()) }()
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, dir, "the refusal must name DATA_DIR")
|
||||
assert.Contains(t, stderr, "running webhooker")
|
||||
assert.Equal(
|
||||
t, placeholderHash, storedHash(t, dir),
|
||||
"a refused reset must not touch the stored credential",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMissingDataDirCreatesNothing pins the side effect that must not
|
||||
// happen. datadir.Acquire calls os.MkdirAll, so reaching it with a
|
||||
// mistyped DATA_DIR would build the directory, take a lock in it and
|
||||
// migrate an empty database there — reporting success against a
|
||||
// deployment that does not exist.
|
||||
func TestMissingDataDirCreatesNothing(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "typo", "webhooker")
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, dir)
|
||||
|
||||
_, err := os.Stat(dir)
|
||||
assert.ErrorIs(
|
||||
t, err, os.ErrNotExist,
|
||||
"a mistyped DATA_DIR must not be created",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMissingDatabaseCreatesNothing covers the directory that exists
|
||||
// but holds no deployment: an empty volume, or the wrong one. Nothing
|
||||
// may be written there either, lock file included.
|
||||
func TestMissingDatabaseCreatesNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, database.MainDBFileName)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(
|
||||
t, entries,
|
||||
"nothing may be created in a directory holding no database",
|
||||
)
|
||||
}
|
||||
|
||||
// TestUnknownUserFails states the decision: resetpw changes an
|
||||
// existing account's password and never creates an account. A typo in
|
||||
// the username must say so rather than quietly adding a second user.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestUnknownUserFails(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
code, _, stderr := run(t, newPassword+"\n", "amdin")
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.Contains(t, stderr, "amdin")
|
||||
assert.Equal(t, placeholderHash, storedHash(t, dir))
|
||||
|
||||
var count int64
|
||||
|
||||
db, err := database.Open(dir, testLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, db.DB().Model(&database.User{}).
|
||||
Count(&count).Error)
|
||||
assert.EqualValues(
|
||||
t, 1, count, "no account may have been created",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRejectsUnusablePasswords covers what standard input can carry by
|
||||
// accident: nothing at all, and a stray keystroke. Either would
|
||||
// otherwise become the account's only credential.
|
||||
//
|
||||
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
|
||||
func TestRejectsUnusablePasswords(t *testing.T) {
|
||||
dir := newDeployment(t)
|
||||
|
||||
for name, stdin := range map[string]string{
|
||||
"empty": "",
|
||||
"newline": "\n",
|
||||
"short": "hunter2\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
code, _, stderr := run(t, stdin, operatorUser)
|
||||
|
||||
require.Equal(t, exitFailure, code)
|
||||
assert.NotEmpty(t, stderr)
|
||||
assert.Equal(
|
||||
t, placeholderHash, storedHash(t, dir),
|
||||
"a rejected password must not be stored",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageErrors pins the statuses a caller can script against: 2 for
|
||||
// being called wrong, which is not the same as a refusal.
|
||||
func TestUsageErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, args := range map[string][]string{
|
||||
"no username": {},
|
||||
"two usernames": {"admin", "root"},
|
||||
"unknown flag": {"-force", "admin"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := resetpw.Run(
|
||||
args, strings.NewReader(""), &stdout, &stderr,
|
||||
)
|
||||
|
||||
assert.Equal(t, 2, code)
|
||||
assert.NotEmpty(t, stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user