diff --git a/src/bootstrap/bootstrap.go b/src/bootstrap/bootstrap.go index 6df8cfc..a1ef46a 100644 --- a/src/bootstrap/bootstrap.go +++ b/src/bootstrap/bootstrap.go @@ -55,8 +55,8 @@ 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 one-time password API remains available until first fetch. ║") - color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║") + 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 7325c4a..1779e66 100644 --- a/src/controller/admin/auth_controller.go +++ b/src/controller/admin/auth_controller.go @@ -35,7 +35,7 @@ type MeResponse struct { PasswordIsDefault bool `json:"password_is_default" example:"true"` } -// InitialPasswordResponse is returned by the one-time initial password API. +// InitialPasswordResponse is returned by the initial password API. type InitialPasswordResponse struct { Username string `json:"username" example:"admin"` Password string `json:"password" example:"a1b2c3d4e5f6"` @@ -84,16 +84,17 @@ func (c *BaseAdminController) Login(ctx echo.Context) error { }) } -// GetInitialPassword returns the one-time initial admin password. -// @Summary Get initial admin password (one-time) -// @Description Returns the initial random admin password once, then invalidates it. +// 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.ConsumeInitialAdminPassword() + password, err := data.GetInitialAdminPassword() if err != nil { if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) { return c.FailJson(ctx, constant.InitialAdminPasswordErr) diff --git a/src/model/data/admin_user_data.go b/src/model/data/admin_user_data.go index 786827a..c9a7bbe 100644 --- a/src/model/data/admin_user_data.go +++ b/src/model/data/admin_user_data.go @@ -106,7 +106,7 @@ func initAdminPasswordState(plain string) error { { Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched, Value: "false", Type: mdb.SettingTypeBool, - Description: "Whether initial admin password has been fetched", + Description: "Whether initial admin password plaintext has been cleared", }, { Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged, @@ -138,56 +138,51 @@ func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error { }).Create(&row).Error } -// ConsumeInitialAdminPassword returns the one-time initial admin password. -// After a successful read, the plaintext is deleted and cannot be fetched -// again. -func ConsumeInitialAdminPassword() (string, error) { - var password string - err := dao.Mdb.Transaction(func(tx *gorm.DB) error { - row := new(mdb.Setting) - if err := tx.Model(&mdb.Setting{}). - Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). - Limit(1). - Find(row).Error; err != nil { - return err - } - if row.ID == 0 || row.Value == "" { - var fetched mdb.Setting - if err := tx.Model(&mdb.Setting{}). - Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched). - Limit(1). - Find(&fetched).Error; err != nil { - return err - } - if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") { - return ErrInitAdminPasswordAlreadyFetched - } - return ErrInitAdminPasswordUnavailable - } - password = row.Value - res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return ErrInitAdminPasswordAlreadyFetched - } - return upsertSettingRow(tx, mdb.Setting{ - Group: mdb.SettingGroupSystem, - Key: mdb.SettingKeyInitAdminPasswordFetched, - Value: "true", - Type: mdb.SettingTypeBool, - Description: "Whether initial admin password has been fetched", - }) - }) - if err != nil { +// GetInitialAdminPassword returns the initial admin password plaintext while +// it is still available. The plaintext remains readable until the admin +// password is changed, after which the stored plaintext is deleted. +func GetInitialAdminPassword() (string, error) { + row := new(mdb.Setting) + if err := dao.Mdb.Model(&mdb.Setting{}). + Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). + Limit(1). + Find(row).Error; err != nil { return "", err } - settingsCacheMu.Lock() - delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) - settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" - settingsCacheMu.Unlock() - return password, nil + if row.ID != 0 && row.Value != "" { + return row.Value, nil + } + + var fetched mdb.Setting + if err := dao.Mdb.Model(&mdb.Setting{}). + Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched). + Limit(1). + Find(&fetched).Error; err != nil { + return "", err + } + if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") { + return "", ErrInitAdminPasswordAlreadyFetched + } + return "", ErrInitAdminPasswordUnavailable +} + +func clearInitialAdminPasswordPlain(tx *gorm.DB) error { + res := tx.Unscoped(). + Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain). + Delete(&mdb.Setting{}) + if res.Error != nil { + return res.Error + } + if err := upsertSettingRow(tx, mdb.Setting{ + Group: mdb.SettingGroupSystem, + Key: mdb.SettingKeyInitAdminPasswordFetched, + Value: "true", + Type: mdb.SettingTypeBool, + Description: "Whether initial admin password plaintext has been cleared", + }); err != nil { + return err + } + return nil } // GetInitialAdminPasswordHashInfo returns the initial-password fingerprint @@ -250,7 +245,9 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) { return u, err } -// UpdateAdminUserPassword rehashes and persists a new password. +// 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. func UpdateAdminUserPassword(id uint64, newPlain string) error { hash, err := HashPassword(newPlain) if err != nil { @@ -293,6 +290,9 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error { }); err != nil { return err } + if err := clearInitialAdminPasswordPlain(tx); err != nil { + return err + } changedCacheValue = changedValue return nil }) @@ -301,6 +301,8 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error { } if changedCacheValue != "" { settingsCacheMu.Lock() + delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain) + settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true" settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue settingsCacheMu.Unlock() } diff --git a/src/model/data/admin_user_data_test.go b/src/model/data/admin_user_data_test.go index 5672c67..d5d7e74 100644 --- a/src/model/data/admin_user_data_test.go +++ b/src/model/data/admin_user_data_test.go @@ -42,7 +42,7 @@ func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) { } } -func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) { +func TestGetInitialAdminPasswordKeepsPlaintext(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -51,14 +51,58 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) { t.Fatalf("seed initial password state: %v", err) } - got, err := ConsumeInitialAdminPassword() + got, err := GetInitialAdminPassword() if err != nil { - t.Fatalf("consume initial password: %v", err) + t.Fatalf("get initial password: %v", err) } if got != password { t.Fatalf("password = %q, want %q", got, password) } + 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 != 1 { + t.Fatalf("plaintext setting rows after read = %d, want 1", count) + } + if GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) { + t.Fatal("expected fetched flag to stay false before password change") + } +} + +func TestUpdateAdminUserPasswordHardDeletesInitialPasswordPlaintext(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + const ( + oldPassword = "init-pass-plain" + newPassword = "new-password-123" + ) + if err := initAdminPasswordState(oldPassword); err != nil { + t.Fatalf("seed initial password state: %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{}). @@ -67,10 +111,10 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) { t.Fatalf("count plaintext setting: %v", err) } if count != 0 { - t.Fatalf("plaintext setting rows after consume = %d, want 0", count) + 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") + t.Fatal("expected fetched flag to be true after password change") } } diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index 7a83da6..5d0a0ae 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -290,8 +290,9 @@ func TestAdminChangePassword(t *testing.T) { } } -// TestAdminInitPasswordFlow verifies the one-time initial password endpoint -// and hash-based "password changed" detection flow. +// 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) { e := setupTestEnv(t) const initPassword = "init-pass-123456" @@ -336,23 +337,18 @@ func TestAdminInitPasswordFlow(t *testing.T) { Count(&plaintextRows).Error; err != nil { t.Fatalf("count init password plaintext rows: %v", err) } - if plaintextRows != 0 { - t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows) + if plaintextRows != 1 { + t.Fatalf("expected init password plaintext to remain available, got %d rows", plaintextRows) } recFetch2 := doGet(e, "/admin/api/v1/auth/init-password") - if recFetch2.Code != http.StatusBadRequest { - t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String()) + 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"]) } - var respFetch2 map[string]interface{} - if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil { - t.Fatalf("unmarshal second fetch response: %v", err) - } - if got := int(respFetch2["status_code"].(float64)); got != 10040 { - t.Fatalf("second fetch status_code = %d, want 10040; response=%v", got, respFetch2) - } - if got, _ := respFetch2["message"].(string); got != constant.Errno[10040] { - t.Fatalf("second fetch message = %q, want %q; response=%v", got, constant.Errno[10040], respFetch2) + if fetchData2["password"] != initPassword { + t.Fatalf("expected second fetch password %s, got %v", initPassword, fetchData2["password"]) } token := adminLogin(t, e, testAdminUsername, initPassword) @@ -377,6 +373,30 @@ func TestAdminInitPasswordFlow(t *testing.T) { 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). + Count(&plaintextRows).Error; err != nil { + t.Fatalf("count init password plaintext rows after change: %v", err) + } + if plaintextRows != 0 { + t.Fatalf("expected init password plaintext to be hard-deleted after change, got %d rows", plaintextRows) + } + recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token) respMe2 := assertOK(t, recMe2) meData2, _ := respMe2["data"].(map[string]interface{})