fix(backend): bootstrap admin from env instead of seeding admin/admin
007_seed_data.sql shipped a fixed admin account whose bcrypt hash decodes to the password "admin", giving every deployment the same known credentials. The seed row is removed; UserService.EnsureAdmin now creates the administrator on startup from ADMIN_USERNAME / ADMIN_PASSWORD. It is idempotent and never overwrites an existing password, so an operator who rotates the admin password keeps it across restarts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,11 @@ JWT_SECRET=change-me-to-a-random-32-byte-secret
|
|||||||
JWT_ACCESS_TTL=15m
|
JWT_ACCESS_TTL=15m
|
||||||
JWT_REFRESH_TTL=720h
|
JWT_REFRESH_TTL=720h
|
||||||
|
|
||||||
|
# Initial administrator, created on first startup if it does not yet exist.
|
||||||
|
# Changing the password later (via the API) is preserved across restarts.
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me-before-first-run
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Database
|
# Database
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ func main() {
|
|||||||
)
|
)
|
||||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
userSvc := service.NewUserService(userRepo, auditSvc)
|
||||||
|
|
||||||
|
// Bootstrap the initial administrator (idempotent).
|
||||||
|
if err := userSvc.EnsureAdmin(context.Background(), cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||||
|
slog.Error("failed to bootstrap admin user", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type Config struct {
|
|||||||
JWTAccessTTL time.Duration
|
JWTAccessTTL time.Duration
|
||||||
JWTRefreshTTL time.Duration
|
JWTRefreshTTL time.Duration
|
||||||
|
|
||||||
|
// Initial admin bootstrap (applied on startup if the user does not exist)
|
||||||
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
|
|
||||||
@@ -87,6 +91,9 @@ func Load() (*Config, error) {
|
|||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
||||||
|
|
||||||
|
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||||
|
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
||||||
|
|
||||||
DatabaseURL: requireStr("DATABASE_URL"),
|
DatabaseURL: requireStr("DATABASE_URL"),
|
||||||
|
|
||||||
FilesPath: requireStr("FILES_PATH"),
|
FilesPath: requireStr("FILES_PATH"),
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
||||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
userSvc := service.NewUserService(userRepo, auditSvc)
|
||||||
|
|
||||||
|
// Bootstrap the admin account the suite logs in with (replaces the old
|
||||||
|
// hardcoded seed credentials).
|
||||||
|
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
|
||||||
|
|
||||||
// --- Handlers ------------------------------------------------------------
|
// --- Handlers ------------------------------------------------------------
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -21,6 +22,36 @@ func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
|
|||||||
return &UserService{users: users, audit: audit}
|
return &UserService{users: users, audit: audit}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureAdmin creates the initial administrator account if it does not already
|
||||||
|
// exist. It is idempotent and never overwrites an existing user's password, so
|
||||||
|
// an operator who has changed the admin password keeps it across restarts.
|
||||||
|
func (s *UserService) EnsureAdmin(ctx context.Context, username, password string) error {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return fmt.Errorf("EnsureAdmin: username and password must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.users.GetByName(ctx, username); err == nil {
|
||||||
|
return nil // already exists
|
||||||
|
} else if !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return fmt.Errorf("EnsureAdmin: lookup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("EnsureAdmin: hash: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.users.Create(ctx, &domain.User{
|
||||||
|
Name: username,
|
||||||
|
Password: string(hash),
|
||||||
|
IsAdmin: true,
|
||||||
|
CanCreate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("EnsureAdmin: create: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Self-service
|
// Self-service
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ INSERT INTO activity.action_types (name) VALUES
|
|||||||
-- Sessions
|
-- Sessions
|
||||||
('session_terminate');
|
('session_terminate');
|
||||||
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
-- The initial administrator is created at application startup from the
|
||||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
-- ADMIN_USERNAME / ADMIN_PASSWORD environment variables (see UserService.
|
||||||
|
-- EnsureAdmin), so no default credentials are seeded here.
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|
||||||
DELETE FROM core.users WHERE name = 'admin';
|
|
||||||
DELETE FROM activity.action_types;
|
DELETE FROM activity.action_types;
|
||||||
DELETE FROM core.object_types;
|
DELETE FROM core.object_types;
|
||||||
DELETE FROM core.mime_types;
|
DELETE FROM core.mime_types;
|
||||||
|
|||||||
Reference in New Issue
Block a user