611f736088
- 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
197 lines
6.5 KiB
Go
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
|
|
}
|