init
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user