diff --git a/src/bootstrap/bootstrap.go b/src/bootstrap/bootstrap.go index a1ef46a..7148587 100644 --- a/src/bootstrap/bootstrap.go +++ b/src/bootstrap/bootstrap.go @@ -55,8 +55,6 @@ func InitApp() { color.Yellow.Println("║ Default admin account created. Save these credentials now. ║") color.Yellow.Printf("║ Username: %-54s║\n", "admin") color.Yellow.Printf("║ Password: %-54s║\n", initialPassword) - color.Yellow.Println("║ The initial password API remains available until password change. ║") - color.Yellow.Println("║ GET /admin/api/v1/auth/init-password ║") color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝") } if _, err := appjwt.EnsureSecret(); err != nil { diff --git a/src/controller/admin/auth_controller.go b/src/controller/admin/auth_controller.go index 1779e66..e74ea0f 100644 --- a/src/controller/admin/auth_controller.go +++ b/src/controller/admin/auth_controller.go @@ -1,8 +1,6 @@ package admin import ( - "errors" - "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/util/constant" @@ -35,12 +33,6 @@ type MeResponse struct { PasswordIsDefault bool `json:"password_is_default" example:"true"` } -// InitialPasswordResponse is returned by the initial password API. -type InitialPasswordResponse struct { - Username string `json:"username" example:"admin"` - Password string `json:"password" example:"a1b2c3d4e5f6"` -} - // Login verifies credentials, stamps last_login_at, returns a signed JWT. // @Summary Admin login // @Description Verify credentials and return a signed JWT token @@ -84,29 +76,6 @@ func (c *BaseAdminController) Login(ctx echo.Context) error { }) } -// GetInitialPassword returns the initial admin password until it is cleared by -// a successful password change. -// @Summary Get initial admin password -// @Description Returns the initial random admin password until the admin password is changed. -// @Tags Admin Auth -// @Produce json -// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse} -// @Failure 400 {object} response.ApiResponse -// @Router /admin/api/v1/auth/init-password [get] -func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error { - password, err := data.GetInitialAdminPassword() - if err != nil { - if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) { - return c.FailJson(ctx, constant.InitialAdminPasswordErr) - } - return c.FailJson(ctx, err) - } - return c.SucJson(ctx, InitialPasswordResponse{ - Username: "admin", - Password: password, - }) -} - // GetInitialPasswordHash returns the initial-password fingerprint so the // frontend can warn until the password is changed. // @Summary Get initial admin password hash diff --git a/src/install/installer.go b/src/install/installer.go index 15f8fa9..58f75a2 100644 --- a/src/install/installer.go +++ b/src/install/installer.go @@ -3,23 +3,25 @@ // When the .env config file is absent (or has install=true) the HTTP start // command calls RunInstallServer, which listens on the same address the main // server will eventually use (default :8000) and mounts two JSON endpoints -// under /install consumed by the frontend install UI: +// consumed by the frontend install UI: // -// GET /install/defaults — default field values for the form -// POST /install — validate + write .env, then shut down +// GET /api/install/defaults — default field values for the form +// POST /api/install — validate + initialize install state, then shut down // // The HTTP listen address is submitted as two separate fields (http_bind_addr // and http_bind_port) and combined internally as "ADDR:PORT" before writing // the http_listen key in .env. This makes the form easier for users who only // want to change the port without touching the bind address. // -// Once the .env is written the install server stops and normal bootstrap -// proceeds on the same port without a restart. +// Once install state is initialized and the .env is finalized with +// install=false, the install server stops and normal bootstrap proceeds on the +// same port without a restart. package install import ( "bytes" "context" + "errors" "fmt" "net/http" "os" @@ -28,10 +30,15 @@ import ( "text/template" "time" + "github.com/GMWalletApp/epusdt/config" + "github.com/GMWalletApp/epusdt/model/dao" + "github.com/GMWalletApp/epusdt/model/data" luluHttp "github.com/GMWalletApp/epusdt/util/http" + appLog "github.com/GMWalletApp/epusdt/util/log" "github.com/gookit/color" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "go.uber.org/zap" ) // DefaultInstallAddr is the listen address used by the install API. @@ -59,6 +66,14 @@ type InstallRequest struct { OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"` } +// InstallSubmitResponse is returned when the install request succeeds. +type InstallSubmitResponse struct { + // Completion message for the install request. + Message string `json:"message" example:"install complete, starting server…"` + // Initial admin password, returned only while the plaintext is still available. + InitPassword string `json:"init_password,omitempty" example:"a1b2c3d4e5f6"` +} + // InstallDefaults returns sensible default values for the install form. func InstallDefaults() InstallRequest { return InstallRequest{ @@ -149,28 +164,20 @@ func (h *installHandler) GetDefaults(c echo.Context) error { return c.JSON(http.StatusOK, InstallDefaults()) } -// Submit validates the install payload, writes the .env file, and signals -// the install server to shut down so the main bootstrap can proceed. +// Submit validates the install payload, writes the .env file, initializes the +// minimum DB/admin state, and signals the install server to shut down so the +// main bootstrap can proceed. // http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce // the http_listen config key (e.g. 0.0.0.0:8000). // // @Summary Install — submit configuration -// @Description Validates the submitted configuration and writes the .env file. -// -// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for -// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively). -// http_bind_port must be in the range 1–65535 if provided. -// app_uri is required. All other fields are optional and fall back to -// the defaults returned by GET /api/install/defaults. -// Sets install=false in the written .env, then shuts down the install -// server so that normal application bootstrap starts on the same port. -// After installation completes this route is no longer served. -// +// @Description Validates the submitted configuration, writes install=true, performs the minimum DB setup needed to ensure the default admin exists, optionally returns init_password, then rewrites install=false before shutting down the install server. +// @Description http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for http_listen. app_uri is required; other fields fall back to GET /api/install/defaults. // @Tags Install // @Accept json // @Produce json // @Param body body InstallRequest true "Install configuration" -// @Success 200 {object} map[string]string "message" +// @Success 200 {object} InstallSubmitResponse // @Failure 400 {object} map[string]string "error" // @Failure 500 {object} map[string]string "error" // @Router /api/install [post] @@ -210,16 +217,26 @@ func (h *installHandler) Submit(c echo.Context) error { req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry } - if err := writeEnvFile(h.envFilePath, req); err != nil { + if err := writeEnvFile(h.envFilePath, req, true); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + } + initPassword, err := initializeInstallState(h.envFilePath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + } + if err := writeEnvFile(h.envFilePath, req, false); err != nil { return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) } go func() { close(h.done) }() - return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"}) + return c.JSON(http.StatusOK, InstallSubmitResponse{ + Message: "install complete, starting server…", + InitPassword: initPassword, + }) } -// RunInstallServer starts the install REST API on listenAddr (default :8000) -// under the /install path and blocks until the .env file has been written. +// RunInstallServer starts the install UI and REST API on listenAddr +// (default :8000), then blocks until installation has been finalized. // The caller should then proceed with normal app initialisation (bootstrap.InitApp). func RunInstallServer(listenAddr, envFilePath string) { if listenAddr == "" { @@ -264,13 +281,18 @@ var formControlledKeys = map[string]bool{ "install": true, } -// writeEnvFile renders and writes a minimal .env file. +type envTemplateData struct { + *InstallRequest + InstallValue string +} + +// writeEnvFile renders and atomically writes a minimal .env file. // If the file already exists, values for keys that are NOT controlled by the // install form are preserved from the existing file so that operator-specific // settings (tg_bot_token, db_type, etc.) survive a re-install. // Keys that the form controls (app_uri, http_listen, …) always use the // submitted values. -func writeEnvFile(path string, r *InstallRequest) error { +func writeEnvFile(path string, r *InstallRequest, installEnabled bool) error { // Collect existing non-empty key→value pairs for non-form keys. existingValues := map[string]string{} if data, err := os.ReadFile(path); err == nil { @@ -295,7 +317,14 @@ func writeEnvFile(path string, r *InstallRequest) error { // Render the template into a buffer first. var buf bytes.Buffer - if err := envTemplate.Execute(&buf, r); err != nil { + renderData := envTemplateData{ + InstallRequest: r, + InstallValue: "false", + } + if installEnabled { + renderData.InstallValue = "true" + } + if err := envTemplate.Execute(&buf, renderData); err != nil { return fmt.Errorf("render env template: %w", err) } @@ -315,13 +344,96 @@ func writeEnvFile(path string, r *InstallRequest) error { } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + f, err := os.CreateTemp(filepath.Dir(path), ".env.tmp.*") if err != nil { - return fmt.Errorf("open config file: %w", err) + return fmt.Errorf("open temp config file: %w", err) + } + tempPath := f.Name() + defer func() { + _ = os.Remove(tempPath) + }() + if _, err = fmt.Fprint(f, strings.Join(lines, "\n")); err != nil { + _ = f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + if err = os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace config file: %w", err) + } + return nil +} + +func initializeInstallState(envFilePath string) (string, error) { + if err := initInstallConfig(envFilePath); err != nil { + return "", err + } + if err := initInstallDatabases(); err != nil { + closeInstallDatabases() + return "", err + } + defer closeInstallDatabases() + + password, created, err := data.EnsureDefaultAdmin() + if err != nil { + return "", err + } + if created { + password = strings.TrimSpace(password) + if password == "" { + return "", errors.New("default admin created without initial password") + } + return password, nil + } + + password, err = data.GetInitialAdminPassword() + if err != nil { + if errors.Is(err, data.ErrInitAdminPasswordUnavailable) || errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) { + return "", nil + } + return "", err + } + return strings.TrimSpace(password), nil +} + +func initInstallConfig(envFilePath string) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("init config: %v", recovered) + } + }() + config.SetConfigPath(envFilePath) + config.Init() + return nil +} + +func initInstallDatabases() error { + if appLog.Sugar == nil { + appLog.Sugar = zap.NewNop().Sugar() + } + if err := dao.DBInit(); err != nil { + return fmt.Errorf("init store db: %w", err) + } + if err := dao.RuntimeInit(); err != nil { + return fmt.Errorf("init runtime db: %w", err) + } + return nil +} + +func closeInstallDatabases() { + if dao.RuntimeDB != nil { + if db, err := dao.RuntimeDB.DB(); err == nil { + _ = db.Close() + } + dao.RuntimeDB = nil + } + if dao.Mdb != nil { + if db, err := dao.Mdb.DB(); err == nil { + _ = db.Close() + } + dao.Mdb = nil } - defer f.Close() - _, err = fmt.Fprint(f, strings.Join(lines, "\n")) - return err } var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}} @@ -360,5 +472,5 @@ order_notice_max_retry={{.OrderNoticeMaxRetry}} api_rate_url= # Set to true to re-run the install wizard on next startup. -install=false +install={{.InstallValue}} `)) diff --git a/src/install/installer_test.go b/src/install/installer_test.go index 48d1589..5a0b72e 100644 --- a/src/install/installer_test.go +++ b/src/install/installer_test.go @@ -10,9 +10,89 @@ import ( "testing" "time" + "github.com/GMWalletApp/epusdt/config" + "github.com/GMWalletApp/epusdt/model/dao" + "github.com/GMWalletApp/epusdt/model/data" "github.com/labstack/echo/v4" + "github.com/spf13/viper" ) +func resetInstallTestState(t *testing.T) { + t.Helper() + + closeInstallDatabases() + dao.ResetMdbTableInitForTest() + config.SetConfigPath("") + viper.Reset() + + t.Cleanup(func() { + closeInstallDatabases() + dao.ResetMdbTableInitForTest() + config.SetConfigPath("") + viper.Reset() + }) +} + +func newInstallTestAPI(envPath string) (*echo.Echo, *installHandler) { + h := &installHandler{envFilePath: envPath, done: make(chan struct{})} + e := echo.New() + e.POST("/install", h.Submit) + return e, h +} + +func submitInstallRequest(t *testing.T, e *echo.Echo, payload string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + return body +} + +func assertDoneClosed(t *testing.T, done <-chan struct{}) { + t.Helper() + + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("handler did not close done channel within timeout") + } +} + +func changeInstalledAdminPassword(t *testing.T, envPath, newPassword string) { + t.Helper() + + if err := initInstallConfig(envPath); err != nil { + t.Fatalf("reopen install config: %v", err) + } + if err := initInstallDatabases(); err != nil { + t.Fatalf("reopen install databases: %v", err) + } + defer closeInstallDatabases() + + user, err := data.GetAdminUserByUsername("admin") + if err != nil { + t.Fatalf("load admin user: %v", err) + } + if user.ID == 0 { + t.Fatal("expected seeded admin user") + } + if err := data.UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil { + t.Fatalf("change admin password: %v", err) + } +} + func TestInstallDefaults(t *testing.T) { d := InstallDefaults() if d.AppName != "epusdt" { @@ -43,7 +123,7 @@ func TestWriteEnvFile(t *testing.T) { OrderExpirationTime: 15, OrderNoticeMaxRetry: 3, } - if err := writeEnvFile(path, req); err != nil { + if err := writeEnvFile(path, req, false); err != nil { t.Fatalf("writeEnvFile: %v", err) } @@ -144,29 +224,30 @@ func TestInstallServerServesSPAOnInstallRoute(t *testing.T) { } } -func TestInstallAPISubmit(t *testing.T) { +func TestInstallAPISubmitReturnsInitialPassword(t *testing.T) { + resetInstallTestState(t) + dir := t.TempDir() envPath := filepath.Join(dir, ".env") - h := &installHandler{envFilePath: envPath, done: make(chan struct{})} - e := echo.New() - e.POST("/install", h.Submit) + e, h := newInstallTestAPI(envPath) payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}` - req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + rec := submitInstallRequest(t, e, payload) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) } - // done channel should be closed after successful submit - select { - case <-h.done: - case <-time.After(500 * time.Millisecond): - t.Fatal("handler did not close done channel within timeout") + body := decodeBody(t, rec) + if got, _ := body["message"].(string); got == "" { + t.Fatalf("expected success message, got body=%v", body) } + initPassword, _ := body["init_password"].(string) + if strings.TrimSpace(initPassword) == "" { + t.Fatalf("expected non-empty init_password, got body=%v", body) + } + assertDoneClosed(t, h.done) + data, err := os.ReadFile(envPath) if err != nil { t.Fatalf("env file not written: %v", err) @@ -178,20 +259,107 @@ func TestInstallAPISubmit(t *testing.T) { if !strings.Contains(content, "http_listen=0.0.0.0:8000") { t.Errorf("env file missing http_listen; content:\n%s", content) } + if !strings.Contains(content, "install=false") { + t.Errorf("env file missing install=false; content:\n%s", content) + } +} + +func TestInstallAPISubmitRepeatInstallReturnsSamePasswordWhilePlaintextExists(t *testing.T) { + resetInstallTestState(t) + + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}` + + e1, h1 := newInstallTestAPI(envPath) + rec1 := submitInstallRequest(t, e1, payload) + if rec1.Code != http.StatusOK { + t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String()) + } + body1 := decodeBody(t, rec1) + password1, _ := body1["init_password"].(string) + if strings.TrimSpace(password1) == "" { + t.Fatalf("expected first init_password, got body=%v", body1) + } + assertDoneClosed(t, h1.done) + + e2, h2 := newInstallTestAPI(envPath) + rec2 := submitInstallRequest(t, e2, payload) + if rec2.Code != http.StatusOK { + t.Fatalf("second install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String()) + } + body2 := decodeBody(t, rec2) + password2, _ := body2["init_password"].(string) + if password2 != password1 { + t.Fatalf("expected repeat install to return same init_password %q, got %q", password1, password2) + } + assertDoneClosed(t, h2.done) +} + +func TestInstallAPISubmitRepeatInstallOmitsPasswordAfterPasswordChange(t *testing.T) { + resetInstallTestState(t) + + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"order_expiration_time":10,"order_notice_max_retry":1}` + + e1, h1 := newInstallTestAPI(envPath) + rec1 := submitInstallRequest(t, e1, payload) + if rec1.Code != http.StatusOK { + t.Fatalf("first install status = %d, want 200; body: %s", rec1.Code, rec1.Body.String()) + } + assertDoneClosed(t, h1.done) + + changeInstalledAdminPassword(t, envPath, "new-password-456") + + e2, h2 := newInstallTestAPI(envPath) + rec2 := submitInstallRequest(t, e2, payload) + if rec2.Code != http.StatusOK { + t.Fatalf("repeat install status = %d, want 200; body: %s", rec2.Code, rec2.Body.String()) + } + body2 := decodeBody(t, rec2) + if got, ok := body2["init_password"]; ok { + t.Fatalf("expected init_password to be omitted after password change, got %v", got) + } + if got, _ := body2["message"].(string); got == "" { + t.Fatalf("expected success message, got body=%v", body2) + } + assertDoneClosed(t, h2.done) +} + +func TestInstallAPISubmitInitFailureKeepsInstallMode(t *testing.T) { + resetInstallTestState(t) + + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + + e, h := newInstallTestAPI(envPath) + payload := `{"app_name":"testapp","app_uri":"http://10.0.0.1:8000","http_bind_addr":"0.0.0.0","http_bind_port":8000,"runtime_root_path":"/dev/null/runtime","order_expiration_time":10,"order_notice_max_retry":1}` + rec := submitInstallRequest(t, e, payload) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body: %s", rec.Code, rec.Body.String()) + } + select { + case <-h.done: + t.Fatal("handler should not close done channel on init failure") + case <-time.After(100 * time.Millisecond): + } + contentBytes, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("read env after init failure: %v", err) + } + content := string(contentBytes) + if !strings.Contains(content, "install=true") { + t.Fatalf("expected env to keep install=true after failure; content:\n%s", content) + } } func TestInstallAPISubmitMissingURI(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") - h := &installHandler{envFilePath: envPath, done: make(chan struct{})} - e := echo.New() - e.POST("/install", h.Submit) - - req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(`{"app_name":"x"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + e, _ := newInstallTestAPI(envPath) + rec := submitInstallRequest(t, e, `{"app_name":"x"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) @@ -205,15 +373,8 @@ func TestInstallAPISubmitInvalidPort(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") - h := &installHandler{envFilePath: envPath, done: make(chan struct{})} - e := echo.New() - e.POST("/install", h.Submit) - - payload := `{"app_uri":"http://example.com","http_bind_port":99999}` - req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) + e, _ := newInstallTestAPI(envPath) + rec := submitInstallRequest(t, e, `{"app_uri":"http://example.com","http_bind_port":99999}`) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String()) diff --git a/src/middleware/check_jwt.go b/src/middleware/check_jwt.go index 0149bb5..19556a1 100644 --- a/src/middleware/check_jwt.go +++ b/src/middleware/check_jwt.go @@ -27,6 +27,9 @@ const ( func CheckAdminJWT() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { + if ctx.Path() == "" { + return next(ctx) + } token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization")) if err != nil { return err diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 1512bf9..b7a2fcf 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -14,6 +14,12 @@ import ( var once sync.Once +// ResetMdbTableInitForTest resets the install/startup migration guard so tests +// can exercise fresh temporary databases in the same process. +func ResetMdbTableInitForTest() { + once = sync.Once{} +} + // MdbTableInit performs AutoMigrate for all primary DB tables and seeds // the minimum static rows: chains, chain tokens, default settings, and // a Telegram notification channel migrated from legacy settings keys. diff --git a/src/model/data/admin_user_data.go b/src/model/data/admin_user_data.go index c9a7bbe..026437b 100644 --- a/src/model/data/admin_user_data.go +++ b/src/model/data/admin_user_data.go @@ -40,39 +40,47 @@ type InitialAdminPasswordHashInfo struct { // can print it to the console. Idempotent — subsequent calls return // ("", false, nil). func EnsureDefaultAdmin() (password string, created bool, err error) { - if err := purgeDeletedInitialAdminPasswordPlain(); err != nil { + if err := dao.Mdb.Transaction(func(tx *gorm.DB) error { + if err := purgeDeletedInitialAdminPasswordPlainTx(tx); err != nil { + return err + } + var count int64 + if err := tx.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + password = randomAdminPassword() + hash, err := HashPassword(password) + if err != nil { + return err + } + user := &mdb.AdminUser{ + Username: defaultAdminUsername, + PasswordHash: hash, + Status: mdb.AdminUserStatusEnable, + } + if err := tx.Create(user).Error; err != nil { + return err + } + if err := initAdminPasswordStateTx(tx, password); err != nil { + return err + } + created = true + return nil + }); err != nil { return "", false, err } - var count int64 - if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil { - return "", false, err + if created { + cacheInitialAdminPasswordState(password) } - if count > 0 { - return "", false, nil - } - password = randomAdminPassword() - hash, err := HashPassword(password) - if err != nil { - return "", false, err - } - user := &mdb.AdminUser{ - Username: defaultAdminUsername, - PasswordHash: hash, - Status: mdb.AdminUserStatusEnable, - } - if err := dao.Mdb.Create(user).Error; err != nil { - return "", false, err - } - if err := initAdminPasswordState(password); err != nil { - // Account was already created; surface both for visibility while - // preserving created=true and password for emergency fallback. - return password, true, err - } - return password, true, nil + return password, created, nil } -func purgeDeletedInitialAdminPasswordPlain() error { - return dao.Mdb.Unscoped(). +func purgeDeletedInitialAdminPasswordPlainTx(tx *gorm.DB) error { + return tx.Unscoped(). Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain). Delete(&mdb.Setting{}).Error } @@ -91,12 +99,20 @@ func HashInitialAdminPassword(plain string) string { } func initAdminPasswordState(plain string) error { + if err := initAdminPasswordStateTx(dao.Mdb, plain); err != nil { + return err + } + cacheInitialAdminPasswordState(plain) + return nil +} + +func initAdminPasswordStateTx(tx *gorm.DB, plain string) error { hash := HashInitialAdminPassword(plain) settings := []mdb.Setting{ { Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain, Value: plain, Type: mdb.SettingTypeString, - Description: "One-time readable initial admin password", + Description: "Readable initial admin password until password change", }, { Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash, @@ -115,18 +131,21 @@ func initAdminPasswordState(plain string) error { }, } for _, row := range settings { - if err := upsertSettingRow(dao.Mdb, row); err != nil { + if err := upsertSettingRow(tx, row); err != nil { return err } } - // Keep in-process cache coherent for the current process. + return nil +} + +func cacheInitialAdminPasswordState(plain string) { + hash := HashInitialAdminPassword(plain) settingsCacheMu.Lock() settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false" settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false" settingsCacheMu.Unlock() - return nil } func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error { @@ -247,12 +266,13 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) { // UpdateAdminUserPassword rehashes and persists a new password. When the // change succeeds, the stored initial-password plaintext is deleted so it can -// no longer be fetched from the public bootstrap route. +// no longer be returned by the install flow. func UpdateAdminUserPassword(id uint64, newPlain string) error { hash, err := HashPassword(newPlain) if err != nil { return err } + clearedPlaintext := false changedCacheValue := "" err = dao.Mdb.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&mdb.AdminUser{}). @@ -261,8 +281,18 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error { return err } + var plainRow mdb.Setting + if err := tx.Model(&mdb.Setting{}). + Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). + Limit(1). + Find(&plainRow).Error; err != nil { + return err + } + hasPlaintext := plainRow.ID != 0 && strings.TrimSpace(plainRow.Value) != "" + // Old installs may not have initial-password metadata; skip in - // that case to keep password updates backward compatible. + // that case to keep password_changed backward compatible, but still + // clear any leftover plaintext if it exists. var initHashRow mdb.Setting if err := tx.Model(&mdb.Setting{}). Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash). @@ -272,6 +302,12 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error { } initHash := strings.TrimSpace(initHashRow.Value) if initHash == "" { + if hasPlaintext { + if err := clearInitialAdminPasswordPlain(tx); err != nil { + return err + } + clearedPlaintext = true + } return nil } @@ -293,17 +329,22 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error { if err := clearInitialAdminPasswordPlain(tx); err != nil { return err } + clearedPlaintext = true changedCacheValue = changedValue return nil }) if err != nil { return err } - if changedCacheValue != "" { + if clearedPlaintext || changedCacheValue != "" { settingsCacheMu.Lock() - delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) - settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" - settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue + if clearedPlaintext { + delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) + settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" + } + if changedCacheValue != "" { + settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue + } settingsCacheMu.Unlock() } return nil diff --git a/src/model/data/admin_user_data_test.go b/src/model/data/admin_user_data_test.go index d5d7e74..49b8aee 100644 --- a/src/model/data/admin_user_data_test.go +++ b/src/model/data/admin_user_data_test.go @@ -118,6 +118,55 @@ func TestUpdateAdminUserPasswordHardDeletesInitialPasswordPlaintext(t *testing.T } } +func TestUpdateAdminUserPasswordHardDeletesPlaintextWithoutHashMetadata(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + const ( + oldPassword = "legacy-init-pass" + newPassword = "new-password-456" + ) + if err := upsertSettingRow(dao.Mdb, mdb.Setting{ + Group: mdb.SettingGroupSystem, + Key: mdb.SettingKeyInitAdminPasswordPlain, + Value: oldPassword, + Type: mdb.SettingTypeString, + }); err != nil { + t.Fatalf("seed plaintext setting: %v", err) + } + + hash, err := HashPassword(oldPassword) + if err != nil { + t.Fatalf("hash password: %v", err) + } + user := &mdb.AdminUser{ + Username: defaultAdminUsername, + PasswordHash: hash, + Status: mdb.AdminUserStatusEnable, + } + if err := dao.Mdb.Create(user).Error; err != nil { + t.Fatalf("seed admin user: %v", err) + } + + if err := UpdateAdminUserPassword(uint64(user.ID), newPassword); err != nil { + t.Fatalf("update admin password: %v", err) + } + + var count int64 + if err := dao.Mdb.Unscoped(). + Model(&mdb.Setting{}). + Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). + Count(&count).Error; err != nil { + t.Fatalf("count plaintext setting: %v", err) + } + if count != 0 { + t.Fatalf("plaintext setting rows after password change = %d, want 0", count) + } + if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) { + t.Fatal("expected fetched flag to be true after clearing plaintext") + } +} + func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -164,3 +213,55 @@ func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) { t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count) } } + +func TestEnsureDefaultAdminRollsBackWhenInitialPasswordStateFails(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Exec(` +CREATE TRIGGER fail_initial_password_state +BEFORE INSERT ON settings +WHEN NEW.key = 'system.init_admin_password_plain' +BEGIN + SELECT RAISE(FAIL, 'forced initial password state failure'); +END; +`).Error; err != nil { + t.Fatalf("create failure trigger: %v", err) + } + + password, created, err := EnsureDefaultAdmin() + if err == nil { + t.Fatal("expected EnsureDefaultAdmin to fail") + } + if created || password != "" { + t.Fatalf("created=%v password=%q, want no created admin on failure", created, password) + } + + var adminCount int64 + if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&adminCount).Error; err != nil { + t.Fatalf("count admin users after failure: %v", err) + } + if adminCount != 0 { + t.Fatalf("admin users after failed initial password state = %d, want 0", adminCount) + } + + if err := dao.Mdb.Exec(`DROP TRIGGER fail_initial_password_state`).Error; err != nil { + t.Fatalf("drop failure trigger: %v", err) + } + + password, created, err = EnsureDefaultAdmin() + if err != nil { + t.Fatalf("retry EnsureDefaultAdmin: %v", err) + } + if !created || password == "" { + t.Fatalf("retry created=%v password=%q, want new admin with password", created, password) + } + + got, err := GetInitialAdminPassword() + if err != nil { + t.Fatalf("get initial password after retry: %v", err) + } + if got != password { + t.Fatalf("initial password after retry = %q, want %q", got, password) + } +} diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index 5d0a0ae..23eb62c 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -290,10 +290,9 @@ func TestAdminChangePassword(t *testing.T) { } } -// TestAdminInitPasswordFlow verifies that the initial password stays readable -// until the admin password is changed, along with the hash-based -// "password changed" detection flow. -func TestAdminInitPasswordFlow(t *testing.T) { +// TestAdminInitialPasswordMetadataFlow verifies that the public plaintext route +// is gone while the hash-based default-password detection flow still works. +func TestAdminInitialPasswordMetadataFlow(t *testing.T) { e := setupTestEnv(t) const initPassword = "init-pass-123456" @@ -321,14 +320,11 @@ func TestAdminInitPasswordFlow(t *testing.T) { t.Fatalf("expected password_changed=false before change, got true") } - recFetch := doGet(e, "/admin/api/v1/auth/init-password") - respFetch := assertOK(t, recFetch) - fetchData, _ := respFetch["data"].(map[string]interface{}) - if fetchData["username"] != testAdminUsername { - t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"]) - } - if fetchData["password"] != initPassword { - t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"]) + token := adminLogin(t, e, testAdminUsername, initPassword) + + recFetch := doGetAdmin(e, "/admin/api/v1/auth/init-password", token) + if recFetch.Code != http.StatusNotFound { + t.Fatalf("expected removed init-password route to return 404, got %d body=%s", recFetch.Code, recFetch.Body.String()) } var plaintextRows int64 if err := dao.Mdb.Unscoped(). @@ -341,18 +337,6 @@ func TestAdminInitPasswordFlow(t *testing.T) { t.Fatalf("expected init password plaintext to remain available, got %d rows", plaintextRows) } - recFetch2 := doGet(e, "/admin/api/v1/auth/init-password") - respFetch2 := assertOK(t, recFetch2) - fetchData2, _ := respFetch2["data"].(map[string]interface{}) - if fetchData2["username"] != testAdminUsername { - t.Fatalf("expected second fetch username=%s, got %v", testAdminUsername, fetchData2["username"]) - } - if fetchData2["password"] != initPassword { - t.Fatalf("expected second fetch password %s, got %v", initPassword, fetchData2["password"]) - } - - token := adminLogin(t, e, testAdminUsername, initPassword) - recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token) respMe1 := assertOK(t, recMe1) meData1, _ := respMe1["data"].(map[string]interface{}) @@ -372,21 +356,6 @@ func TestAdminInitPasswordFlow(t *testing.T) { if got, _ := hashData2["password_changed"].(bool); !got { t.Fatalf("expected password_changed=true after change, got %v", got) } - - recFetch3 := doGet(e, "/admin/api/v1/auth/init-password") - if recFetch3.Code != http.StatusBadRequest { - t.Fatalf("fetch after password change should fail with 400, got %d body=%s", recFetch3.Code, recFetch3.Body.String()) - } - var respFetch3 map[string]interface{} - if err := json.Unmarshal(recFetch3.Body.Bytes(), &respFetch3); err != nil { - t.Fatalf("unmarshal fetch-after-change response: %v", err) - } - if got := int(respFetch3["status_code"].(float64)); got != 10040 { - t.Fatalf("fetch after password change status_code = %d, want 10040; response=%v", got, respFetch3) - } - if got, _ := respFetch3["message"].(string); got != constant.Errno[10040] { - t.Fatalf("fetch after password change message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch3) - } if err := dao.Mdb.Unscoped(). Model(&mdb.Setting{}). Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). diff --git a/src/route/router.go b/src/route/router.go index 70a4721..c5e4dc6 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -263,7 +263,6 @@ func registerAdminRoutes(e *echo.Echo) { // Public (no JWT) adminV1.POST("/auth/login", admin.Ctrl.Login) - adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword) adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash) // Authenticated