286 lines
7.7 KiB
Go
286 lines
7.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// bootstrapEnv is a database, a token path in a directory of its own, and a log
|
|
// buffer to assert against.
|
|
type bootstrapEnv struct {
|
|
db *store.DB
|
|
dir string
|
|
log *slog.Logger
|
|
buf *bytes.Buffer
|
|
}
|
|
|
|
func newBootstrapEnv(t *testing.T) *bootstrapEnv {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
return &bootstrapEnv{
|
|
db: testStore(t),
|
|
dir: t.TempDir(),
|
|
log: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
|
buf: &buf,
|
|
}
|
|
}
|
|
|
|
func (e *bootstrapEnv) path() string { return filepath.Join(e.dir, "bootstrap-token") }
|
|
|
|
func (e *bootstrapEnv) token(t *testing.T) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(e.path())
|
|
if err != nil {
|
|
t.Fatalf("read token file: %v", err)
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
func TestEnsureAdminKeyMintsOnEmptyDatabase(t *testing.T) {
|
|
ctx := context.Background()
|
|
e := newBootstrapEnv(t)
|
|
|
|
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
|
if err != nil {
|
|
t.Fatalf("EnsureAdminKey: %v", err)
|
|
}
|
|
if !minted {
|
|
t.Fatal("minted = false on an empty database")
|
|
}
|
|
|
|
// The token in the file is the credential: it must actually authenticate.
|
|
v := newVerifier(t, e.db)
|
|
id, err := v.Verify(ctx, e.token(t))
|
|
if err != nil {
|
|
t.Fatalf("the bootstrap token does not authenticate: %v", err)
|
|
}
|
|
if !id.IsAdmin() {
|
|
t.Errorf("scope = %q, want admin", id.Scope)
|
|
}
|
|
if id.Name != BootstrapKeyName {
|
|
t.Errorf("name = %q, want %q", id.Name, BootstrapKeyName)
|
|
}
|
|
|
|
// The operator is told where the file is and that it must be removed, and
|
|
// the token itself never reaches the log — the file exists precisely so it
|
|
// does not have to.
|
|
logged := e.buf.String()
|
|
if !strings.Contains(logged, e.path()) {
|
|
t.Errorf("log does not name the token file: %s", logged)
|
|
}
|
|
if strings.Contains(logged, e.token(t)) {
|
|
t.Fatal("the bootstrap token was written to the log")
|
|
}
|
|
if !strings.Contains(logged, id.KeyID) {
|
|
t.Errorf("log does not name the key id, which is the safe half: %s", logged)
|
|
}
|
|
}
|
|
|
|
// The file holds a live admin credential. Anything wider than 0600 hands it to
|
|
// every account on the host.
|
|
func TestBootstrapTokenFileIsPrivate(t *testing.T) {
|
|
ctx := context.Background()
|
|
e := newBootstrapEnv(t)
|
|
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi, err := os.Lstat(e.path())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("mode = %#o, want 0600", perm)
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
t.Error("the token path is a symlink")
|
|
}
|
|
}
|
|
|
|
func TestEnsureAdminKeyIsIdempotent(t *testing.T) {
|
|
ctx := context.Background()
|
|
e := newBootstrapEnv(t)
|
|
|
|
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := e.token(t)
|
|
|
|
// A restart must not mint a second admin key, and must not overwrite the
|
|
// token the operator has not yet collected.
|
|
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if minted {
|
|
t.Error("minted a second bootstrap key")
|
|
}
|
|
if got := e.token(t); got != first {
|
|
t.Error("the token file was rewritten on the second run")
|
|
}
|
|
|
|
n, err := e.db.CountUsableAdminKeys(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("usable admin keys = %d, want 1", n)
|
|
}
|
|
|
|
// A leftover file is a live credential; the operator gets told about it.
|
|
if !strings.Contains(e.buf.String(), "still present") {
|
|
t.Errorf("no warning about the leftover token file: %s", e.buf.String())
|
|
}
|
|
}
|
|
|
|
// A revoked or expired admin key leaves the installation locked out, so
|
|
// restarting must mint a fresh one rather than counting the dead key.
|
|
func TestEnsureAdminKeyRecoversFromUnusableKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("revoked", func(t *testing.T) {
|
|
e := newBootstrapEnv(t)
|
|
_, keyID := mintInto(t, e.db, store.ScopeAdmin, nil)
|
|
if err := e.db.RevokeKey(ctx, keyID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !minted {
|
|
t.Fatal("minted = false with only a revoked admin key")
|
|
}
|
|
})
|
|
|
|
t.Run("expired", func(t *testing.T) {
|
|
e := newBootstrapEnv(t)
|
|
token, _, hash, err := Mint()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
past := time.Now().Add(-time.Hour)
|
|
k := &store.APIKey{ID: keyIDOf(t, token), SecretHash: hash[:],
|
|
Scope: store.ScopeAdmin, Name: "old", ExpiresAt: &past}
|
|
if err := e.db.CreateKey(ctx, k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !minted {
|
|
t.Fatal("minted = false with only an expired admin key")
|
|
}
|
|
})
|
|
|
|
t.Run("project keys do not count", func(t *testing.T) {
|
|
e := newBootstrapEnv(t)
|
|
id := createProjectRow(t, e.db, "demo")
|
|
mintInto(t, e.db, store.ScopeProject, &id)
|
|
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !minted {
|
|
t.Fatal("minted = false with only a project key")
|
|
}
|
|
})
|
|
}
|
|
|
|
// The token path is attacker-controllable on a host where the data directory is
|
|
// created before the server runs. Writing through a planted symlink would put a
|
|
// live admin credential wherever it pointed — /etc/cron.d, another user's
|
|
// ~/.ssh, a world-readable log. The rename-into-place replaces the link instead
|
|
// of following it.
|
|
func TestBootstrapTokenDoesNotFollowASymlink(t *testing.T) {
|
|
ctx := context.Background()
|
|
e := newBootstrapEnv(t)
|
|
|
|
target := filepath.Join(t.TempDir(), "victim")
|
|
if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, e.path()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
|
t.Fatalf("EnsureAdminKey: %v", err)
|
|
}
|
|
|
|
victim, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(victim) != "original\n" {
|
|
t.Fatalf("the symlink target was overwritten with %q", victim)
|
|
}
|
|
fi, err := os.Lstat(e.path())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
t.Fatal("the token path is still a symlink; the token went somewhere else")
|
|
}
|
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
|
t.Errorf("mode = %#o, want 0600", perm)
|
|
}
|
|
if !strings.HasPrefix(e.token(t), Prefix+"_") {
|
|
t.Errorf("the token file does not hold a token")
|
|
}
|
|
}
|
|
|
|
// A directory in the way must fail loudly rather than leave the server running
|
|
// with an admin key nobody can use.
|
|
func TestBootstrapTokenReportsAnUnwritablePath(t *testing.T) {
|
|
ctx := context.Background()
|
|
e := newBootstrapEnv(t)
|
|
blocked := filepath.Join(e.dir, "blocked")
|
|
if err := os.Mkdir(blocked, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := EnsureAdminKey(ctx, e.db, blocked, e.log); err == nil {
|
|
t.Fatal("EnsureAdminKey = nil, want an error when the token cannot be stored")
|
|
}
|
|
// And no key was created: a key whose token was never delivered is just a
|
|
// row nobody can authenticate with.
|
|
n, err := e.db.CountUsableAdminKeys(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("usable admin keys = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// keyIDOf recovers the public half of a token, which is all a test needs to
|
|
// store the row.
|
|
func keyIDOf(t *testing.T, token string) string {
|
|
t.Helper()
|
|
keyID, _, err := Parse(token)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
return keyID
|
|
}
|
|
|
|
// createProjectRow inserts a project so a project-scoped key has something to
|
|
// point at, and returns its id.
|
|
func createProjectRow(t *testing.T, db *store.DB, name string) int64 {
|
|
t.Helper()
|
|
p := store.DefaultProject(name)
|
|
if err := db.CreateProject(context.Background(), p); err != nil {
|
|
t.Fatalf("CreateProject: %v", err)
|
|
}
|
|
return p.ID
|
|
}
|