Files
GraceSolutions 611f736088 feat(auth): default admin/admin bootstrap with forced first-login password change
- Add password_reset_required column (migration 004) + repository support
- auth.Service.ChangePassword verifies current, hashes new, clears flag,
  emits PasswordChange audit events for success and failure
- Bootstrap: when no ORCHESTRAD_BOOTSTRAP_PASSWORD[_FILE] is set, seed
  admin/admin with password_reset_required=true and log a one-time warn
  banner; env/file-supplied passwords keep the flag clear
- Expose passwordResetRequired in UserInfo / /auth/me / login response
- POST /api/v1/auth/change-password behind the authenticated group
- Frontend: /change-password page + ChangePasswordForm, AuthLogin and
  RequireAuth bounce any other route to it while the flag is set
- Docs: DesignSpecification 8.6/8.8 and Template 7.6/7.7 rewritten,
  Trusted Proxy renumbered to 7.8 in the template, acceptance items
  updated to match the new default-credential behavior
2026-04-23 17:36:23 -04:00

197 lines
6.5 KiB
Go

package auth
import (
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
"github.com/google/uuid"
)
// Seeded by migration 001_initial_schema.up.sql.
const adminRoleID = "00000000-0000-0000-0000-000000000002"
// DefaultBootstrapPassword is used to seed the first administrator when the
// operator has not supplied ORCHESTRAD_BOOTSTRAP_PASSWORD or _FILE and the
// users table is empty. The account is flagged as password_reset_required,
// so the operator must change it at first login before they can reach any
// other screen.
const DefaultBootstrapPassword = "admin"
// EnsureBootstrapAdmin seeds or rotates the local administrator account from
// environment variables or secret files. It is safe to call on every startup.
//
// Recognized environment variables (all optional):
//
// ORCHESTRAD_BOOTSTRAP_USERNAME default "admin"
// ORCHESTRAD_BOOTSTRAP_PASSWORD plaintext; takes precedence over _FILE
// ORCHESTRAD_BOOTSTRAP_PASSWORD_FILE path to a file containing the password
// ORCHESTRAD_BOOTSTRAP_EMAIL optional contact email
// ORCHESTRAD_BOOTSTRAP_DISPLAY_NAME defaults to "Administrator"
//
// Behavior:
// - If the user exists and a password was supplied, the password is rotated.
// - If the user exists and no password was supplied, the record is left alone.
// - If the user does not exist and a password was supplied, the user is
// created and granted the Admin role.
// - If the user does not exist, no password was supplied, and the users
// table is empty, the account is seeded with DefaultBootstrapPassword
// and flagged as password_reset_required so the operator is forced to
// replace the well-known default at first login.
func EnsureBootstrapAdmin(db *sql.DB, logger *logging.Logger) error {
username := envOr("ORCHESTRAD_BOOTSTRAP_USERNAME", "admin")
password, source, err := readBootstrapPassword()
if err != nil {
return fmt.Errorf("reading bootstrap password: %w", err)
}
displayName := envOr("ORCHESTRAD_BOOTSTRAP_DISPLAY_NAME", "Administrator")
email := strings.TrimSpace(os.Getenv("ORCHESTRAD_BOOTSTRAP_EMAIL"))
users := repository.NewUserRepository(db)
existing, err := users.GetByUsername(username)
if err != nil {
return fmt.Errorf("looking up bootstrap user %q: %w", username, err)
}
if existing != nil {
if password == "" {
logger.Debug("Bootstrap", "User %q already exists; no password rotation requested", username)
return nil
}
hash, err := crypto.HashPassword(password)
if err != nil {
return fmt.Errorf("hashing bootstrap password: %w", err)
}
existing.PasswordHash = &hash
existing.IsActive = true
if email != "" {
existing.Email = &email
}
if displayName != "" {
existing.DisplayName = &displayName
}
if err := users.Update(existing); err != nil {
return fmt.Errorf("updating bootstrap user %q: %w", username, err)
}
if err := assignAdminRole(db, existing.ID); err != nil {
return fmt.Errorf("assigning admin role to %q: %w", username, err)
}
logger.Info("Bootstrap", "Rotated password for %q (source: %s)", username, source)
return nil
}
// When no password is supplied we only seed the default administrator
// if the users table is empty. forceReset is set whenever the account
// ends up with the well-known default so the first login must pass
// through the change-password flow.
forceReset := false
if password == "" {
empty, err := usersTableEmpty(db)
if err != nil {
return fmt.Errorf("checking users table: %w", err)
}
if !empty {
logger.Debug("Bootstrap", "User %q not found and no bootstrap password supplied; users table is non-empty, skipping", username)
return nil
}
password = DefaultBootstrapPassword
source = "default"
forceReset = true
}
hash, err := crypto.HashPassword(password)
if err != nil {
return fmt.Errorf("hashing bootstrap password: %w", err)
}
now := time.Now().UTC()
user := &models.User{
ID: uuid.New().String(),
Username: username,
PasswordHash: &hash,
DisplayName: strPtr(displayName),
IsActive: true,
IsOIDCUser: false,
PasswordResetRequired: forceReset,
CreatedUTC: now,
UpdatedUTC: now,
}
if email != "" {
user.Email = &email
}
if err := users.Create(user); err != nil {
return fmt.Errorf("creating bootstrap user %q: %w", username, err)
}
if err := assignAdminRole(db, user.ID); err != nil {
return fmt.Errorf("assigning admin role to %q: %w", username, err)
}
if forceReset {
logger.Warn("Bootstrap", "================================================================")
logger.Warn("Bootstrap", " Seeded default administrator account.")
logger.Warn("Bootstrap", " Username: %s", username)
logger.Warn("Bootstrap", " Password: %s", password)
logger.Warn("Bootstrap", " You will be required to change this password at first login.")
logger.Warn("Bootstrap", "================================================================")
} else {
logger.Info("Bootstrap", "Created administrator %q (source: %s)", username, source)
}
return nil
}
func readBootstrapPassword() (string, string, error) {
if pw := os.Getenv("ORCHESTRAD_BOOTSTRAP_PASSWORD"); pw != "" {
return pw, "env", nil
}
if path := strings.TrimSpace(os.Getenv("ORCHESTRAD_BOOTSTRAP_PASSWORD_FILE")); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("reading %s: %w", path, err)
}
pw := strings.TrimRight(strings.TrimRight(string(data), "\n"), "\r")
if pw == "" {
return "", "", errors.New("password file is empty")
}
return pw, "file:" + path, nil
}
return "", "", nil
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func assignAdminRole(db *sql.DB, userID string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(`
INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc)
VALUES (?, ?, ?)
`, userID, adminRoleID, now)
return err
}
func usersTableEmpty(db *sql.DB) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE deleted_utc IS NULL`).Scan(&count); err != nil {
return false, err
}
return count == 0, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}