111 lines
3.7 KiB
Go
111 lines
3.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// BootstrapKeyName is the name given to the first-run admin key, so an operator
|
|
// listing keys can tell it apart from ones they minted themselves.
|
|
const BootstrapKeyName = "bootstrap"
|
|
|
|
// EnsureAdminKey mints an admin key and writes its token to tokenPath when the
|
|
// server has no usable admin key at all, and reports whether it did.
|
|
//
|
|
// This is the only place the server ever writes a token to disk, and it exists
|
|
// because a fresh install would otherwise have no way to authenticate the call
|
|
// that creates the first key. The file is 0600 and the log line tells the
|
|
// operator to delete it once they have copied the token out.
|
|
//
|
|
// "Usable" excludes revoked and expired keys, so an installation whose only
|
|
// admin key was revoked recovers by restarting rather than by hand-editing the
|
|
// database.
|
|
func EnsureAdminKey(ctx context.Context, db *store.DB, tokenPath string, log *slog.Logger) (bool, error) {
|
|
n, err := db.CountUsableAdminKeys(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("count admin keys: %w", err)
|
|
}
|
|
if n > 0 {
|
|
// Nothing to mint. If a token file is still lying around from an earlier
|
|
// bootstrap, say so: it is a live credential until that key is revoked.
|
|
if _, err := os.Lstat(tokenPath); err == nil && log != nil {
|
|
log.Warn("bootstrap token file still present; delete it once the token is stored elsewhere",
|
|
"path", tokenPath)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
token, keyID, hash, err := Mint()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := writeTokenFile(tokenPath, token); err != nil {
|
|
return false, err
|
|
}
|
|
k := &store.APIKey{
|
|
ID: keyID,
|
|
SecretHash: hash[:],
|
|
Scope: store.ScopeAdmin,
|
|
Name: BootstrapKeyName,
|
|
}
|
|
if err := db.CreateKey(ctx, k); err != nil {
|
|
// The file names a key that does not exist. Remove it rather than leave
|
|
// an operator holding a token that will never authenticate.
|
|
_ = os.Remove(tokenPath)
|
|
return false, fmt.Errorf("create bootstrap key: %w", err)
|
|
}
|
|
|
|
if log != nil {
|
|
// The key id is the public half of the token and is safe to log; the
|
|
// token itself is not, which is the whole reason for the file.
|
|
log.Warn("no admin key found; minted a bootstrap admin key",
|
|
"key_id", keyID, "path", tokenPath,
|
|
"action", "read the token, then delete this file")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// writeTokenFile writes the token with 0600 permissions.
|
|
//
|
|
// It writes to a temporary file in the same directory and renames it into
|
|
// place. That is not for atomicity — nothing reads this concurrently — but
|
|
// because rename replaces the destination without following it. Opening the
|
|
// path directly would follow a symlink someone had planted there and write a
|
|
// live credential wherever it pointed.
|
|
func writeTokenFile(path, token string) error {
|
|
dir := filepath.Dir(path)
|
|
f, err := os.CreateTemp(dir, ".bootstrap-token-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create bootstrap token file: %w", err)
|
|
}
|
|
tmp := f.Name()
|
|
defer func() {
|
|
f.Close()
|
|
os.Remove(tmp) // no-op once the rename has succeeded
|
|
}()
|
|
|
|
// CreateTemp already makes the file 0600, but say so explicitly: this is the
|
|
// property that matters and it should not depend on a documented default.
|
|
if err := f.Chmod(0o600); err != nil {
|
|
return fmt.Errorf("chmod bootstrap token file: %w", err)
|
|
}
|
|
if _, err := f.WriteString(token + "\n"); err != nil {
|
|
return fmt.Errorf("write bootstrap token: %w", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return fmt.Errorf("sync bootstrap token: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("close bootstrap token: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("install bootstrap token: %w", err)
|
|
}
|
|
return nil
|
|
}
|