first commit
This commit is contained in:
135
go-api/cmd/api/main.go
Normal file
135
go-api/cmd/api/main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Command api is the Krow HTTP API.
|
||||
//
|
||||
// Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints,
|
||||
// and the entity and current-user endpoints described in docs/api-contract.md.
|
||||
//
|
||||
// Every request outside the public allowlist carries a session cookie that this
|
||||
// process resolves to a real user. The development organization that used to be
|
||||
// injected into every request is gone: identity now comes from the sessions
|
||||
// table, and a database with no users is a database nobody can sign in to,
|
||||
// which is the correct behaviour rather than a gap.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log := newLogger(cfg.Log.Level)
|
||||
log.Info("starting krow-api", "env", cfg.AppEnv, "database", cfg.DB.Redacted())
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
database, err := db.Open(ctx, cfg.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
log.Info("database connected", "schema", cfg.DB.Schema)
|
||||
|
||||
server, err := httpserver.New(cfg, database, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The sweeper's context is cancelled by the same signal that stops the
|
||||
// server, so the ticker goes away with the process rather than outliving
|
||||
// the pool it queries.
|
||||
go sweepSessions(ctx, server.Sessions(), log)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- server.Start() }()
|
||||
log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(),
|
||||
"health", "http://"+server.Addr()+"/health",
|
||||
"cors_origins", cfg.HTTP.CORSOrigins)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
log.Info("shutdown signal received, draining")
|
||||
// context.Background: ctx is already cancelled, and Shutdown needs a
|
||||
// live deadline of its own to drain in-flight requests.
|
||||
return server.Shutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
// sweepInterval is how often dead sessions are collected.
|
||||
//
|
||||
// Sweeping is housekeeping, not correctness: Manager.Authenticate already
|
||||
// refuses an expired session and deletes the row as it finds it, so a session
|
||||
// is never usable between its expiry and the next sweep. This only collects the
|
||||
// rows nobody comes back for. Fifteen minutes keeps the table from growing
|
||||
// without putting a DELETE on any hot path.
|
||||
const sweepInterval = 15 * time.Minute
|
||||
|
||||
// sweepSessions deletes expired sessions until the context is cancelled.
|
||||
//
|
||||
// It runs once immediately so a process that has been down for a while does not
|
||||
// carry a backlog for a further fifteen minutes, then on the ticker. A failed
|
||||
// sweep is logged and retried at the next tick: the table being briefly larger
|
||||
// than it should be is not worth stopping the API for.
|
||||
func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) {
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sweep := func() {
|
||||
// A deadline of its own, so a slow or wedged DELETE cannot leave this
|
||||
// goroutine blocked past shutdown.
|
||||
sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
n, err := sessions.Sweep(sweepCtx)
|
||||
switch {
|
||||
case err != nil && ctx.Err() != nil:
|
||||
// Shutting down; the cancellation is expected, not a failure.
|
||||
case err != nil:
|
||||
log.Warn("session sweep failed", "error", err)
|
||||
case n > 0:
|
||||
log.Info("swept expired sessions", "deleted", n)
|
||||
default:
|
||||
log.Debug("session sweep found nothing to delete")
|
||||
}
|
||||
}
|
||||
|
||||
sweep()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug("session sweeper stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
sweep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
75
go-api/cmd/seed/main.go
Normal file
75
go-api/cmd/seed/main.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Command seed loads the frontend's demo dataset into PostgreSQL.
|
||||
//
|
||||
// Safe to run repeatedly: every record's key is derived deterministically from
|
||||
// its source id and written with ON CONFLICT DO UPDATE inside one transaction,
|
||||
// so re-running restores the seeded values without duplicating a row or
|
||||
// deleting anything. See internal/seeder.
|
||||
//
|
||||
// make seed
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/config"
|
||||
"github.com/krow/krow-backend/go-api/internal/db"
|
||||
"github.com/krow/krow-backend/go-api/internal/seeder"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "seed failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fixture, err := seeder.Load(cfg.Seed.FixturePath)
|
||||
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()
|
||||
|
||||
started := time.Now()
|
||||
result, err := seeder.New(database.Pool, fixture, time.Now()).Run(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(result.Counts))
|
||||
total := 0
|
||||
for name, n := range result.Counts {
|
||||
names = append(names, name)
|
||||
total += n
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
fmt.Printf("seeded into %s (organization %s)\n", cfg.DB.Name, result.OrgID)
|
||||
for _, name := range names {
|
||||
fmt.Printf(" %-16s %4d\n", name, result.Counts[name])
|
||||
}
|
||||
fmt.Printf(" %-16s %4d records in %s\n", "TOTAL", total, time.Since(started).Round(time.Millisecond))
|
||||
if result.Pruned > 0 {
|
||||
// Shift records are a rolling window; ones that fell out of it are
|
||||
// removed. Said out loud, because a seed that deletes should say so.
|
||||
fmt.Printf(" %-16s %4d stale shift record(s) outside the current window\n", "PRUNED", result.Pruned)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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