mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
auth: keep init password available until password change
This commit is contained in:
@@ -55,8 +55,8 @@ func InitApp() {
|
|||||||
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
|
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
|
||||||
color.Yellow.Printf("║ Username: %-54s║\n", "admin")
|
color.Yellow.Printf("║ Username: %-54s║\n", "admin")
|
||||||
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
|
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
|
||||||
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
|
color.Yellow.Println("║ The initial password API remains available until password change. ║")
|
||||||
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password ║")
|
||||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||||
}
|
}
|
||||||
if _, err := appjwt.EnsureSecret(); err != nil {
|
if _, err := appjwt.EnsureSecret(); err != nil {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type MeResponse struct {
|
|||||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
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 {
|
type InitialPasswordResponse struct {
|
||||||
Username string `json:"username" example:"admin"`
|
Username string `json:"username" example:"admin"`
|
||||||
Password string `json:"password" example:"a1b2c3d4e5f6"`
|
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.
|
// GetInitialPassword returns the initial admin password until it is cleared by
|
||||||
// @Summary Get initial admin password (one-time)
|
// a successful password change.
|
||||||
// @Description Returns the initial random admin password once, then invalidates it.
|
// @Summary Get initial admin password
|
||||||
|
// @Description Returns the initial random admin password until the admin password is changed.
|
||||||
// @Tags Admin Auth
|
// @Tags Admin Auth
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
// @Success 200 {object} response.ApiResponse{data=admin.InitialPasswordResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /admin/api/v1/auth/init-password [get]
|
// @Router /admin/api/v1/auth/init-password [get]
|
||||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||||
password, err := data.ConsumeInitialAdminPassword()
|
password, err := data.GetInitialAdminPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
||||||
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func initAdminPasswordState(plain string) error {
|
|||||||
{
|
{
|
||||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||||
Value: "false", Type: mdb.SettingTypeBool,
|
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,
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||||
@@ -138,56 +138,51 @@ func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
|||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeInitialAdminPassword returns the one-time initial admin password.
|
// GetInitialAdminPassword returns the initial admin password plaintext while
|
||||||
// After a successful read, the plaintext is deleted and cannot be fetched
|
// it is still available. The plaintext remains readable until the admin
|
||||||
// again.
|
// password is changed, after which the stored plaintext is deleted.
|
||||||
func ConsumeInitialAdminPassword() (string, error) {
|
func GetInitialAdminPassword() (string, error) {
|
||||||
var password string
|
|
||||||
err := dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
|
||||||
row := new(mdb.Setting)
|
row := new(mdb.Setting)
|
||||||
if err := tx.Model(&mdb.Setting{}).
|
if err := dao.Mdb.Model(&mdb.Setting{}).
|
||||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Find(row).Error; err != nil {
|
Find(row).Error; err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if row.ID == 0 || row.Value == "" {
|
if row.ID != 0 && row.Value != "" {
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
var fetched mdb.Setting
|
var fetched mdb.Setting
|
||||||
if err := tx.Model(&mdb.Setting{}).
|
if err := dao.Mdb.Model(&mdb.Setting{}).
|
||||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordFetched).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Find(&fetched).Error; err != nil {
|
Find(&fetched).Error; err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
|
if strings.EqualFold(strings.TrimSpace(fetched.Value), "true") {
|
||||||
return ErrInitAdminPasswordAlreadyFetched
|
return "", ErrInitAdminPasswordAlreadyFetched
|
||||||
}
|
}
|
||||||
return ErrInitAdminPasswordUnavailable
|
return "", ErrInitAdminPasswordUnavailable
|
||||||
}
|
}
|
||||||
password = row.Value
|
|
||||||
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
func clearInitialAdminPasswordPlain(tx *gorm.DB) error {
|
||||||
|
res := tx.Unscoped().
|
||||||
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
Delete(&mdb.Setting{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
if err := upsertSettingRow(tx, mdb.Setting{
|
||||||
return ErrInitAdminPasswordAlreadyFetched
|
|
||||||
}
|
|
||||||
return upsertSettingRow(tx, mdb.Setting{
|
|
||||||
Group: mdb.SettingGroupSystem,
|
Group: mdb.SettingGroupSystem,
|
||||||
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
Key: mdb.SettingKeyInitAdminPasswordFetched,
|
||||||
Value: "true",
|
Value: "true",
|
||||||
Type: mdb.SettingTypeBool,
|
Type: mdb.SettingTypeBool,
|
||||||
Description: "Whether initial admin password has been fetched",
|
Description: "Whether initial admin password plaintext has been cleared",
|
||||||
})
|
}); err != nil {
|
||||||
})
|
return err
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
settingsCacheMu.Lock()
|
return nil
|
||||||
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
|
||||||
settingsCacheMu.Unlock()
|
|
||||||
return password, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
// GetInitialAdminPasswordHashInfo returns the initial-password fingerprint
|
||||||
@@ -250,7 +245,9 @@ func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
|
|||||||
return u, err
|
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 {
|
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||||
hash, err := HashPassword(newPlain)
|
hash, err := HashPassword(newPlain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -293,6 +290,9 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := clearInitialAdminPasswordPlain(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
changedCacheValue = changedValue
|
changedCacheValue = changedValue
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -301,6 +301,8 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
}
|
}
|
||||||
if changedCacheValue != "" {
|
if changedCacheValue != "" {
|
||||||
settingsCacheMu.Lock()
|
settingsCacheMu.Lock()
|
||||||
|
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
||||||
settingsCacheMu.Unlock()
|
settingsCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
func TestGetInitialAdminPasswordKeepsPlaintext(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -51,14 +51,58 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
|||||||
t.Fatalf("seed initial password state: %v", err)
|
t.Fatalf("seed initial password state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := ConsumeInitialAdminPassword()
|
got, err := GetInitialAdminPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("consume initial password: %v", err)
|
t.Fatalf("get initial password: %v", err)
|
||||||
}
|
}
|
||||||
if got != password {
|
if got != password {
|
||||||
t.Fatalf("password = %q, want %q", 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
|
var count int64
|
||||||
if err := dao.Mdb.Unscoped().
|
if err := dao.Mdb.Unscoped().
|
||||||
Model(&mdb.Setting{}).
|
Model(&mdb.Setting{}).
|
||||||
@@ -67,10 +111,10 @@ func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
|||||||
t.Fatalf("count plaintext setting: %v", err)
|
t.Fatalf("count plaintext setting: %v", err)
|
||||||
}
|
}
|
||||||
if count != 0 {
|
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) {
|
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
|
||||||
t.Fatal("expected fetched flag to be true")
|
t.Fatal("expected fetched flag to be true after password change")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,8 +290,9 @@ func TestAdminChangePassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAdminInitPasswordFlow verifies the one-time initial password endpoint
|
// TestAdminInitPasswordFlow verifies that the initial password stays readable
|
||||||
// and hash-based "password changed" detection flow.
|
// until the admin password is changed, along with the hash-based
|
||||||
|
// "password changed" detection flow.
|
||||||
func TestAdminInitPasswordFlow(t *testing.T) {
|
func TestAdminInitPasswordFlow(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
const initPassword = "init-pass-123456"
|
const initPassword = "init-pass-123456"
|
||||||
@@ -336,23 +337,18 @@ func TestAdminInitPasswordFlow(t *testing.T) {
|
|||||||
Count(&plaintextRows).Error; err != nil {
|
Count(&plaintextRows).Error; err != nil {
|
||||||
t.Fatalf("count init password plaintext rows: %v", err)
|
t.Fatalf("count init password plaintext rows: %v", err)
|
||||||
}
|
}
|
||||||
if plaintextRows != 0 {
|
if plaintextRows != 1 {
|
||||||
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows)
|
t.Fatalf("expected init password plaintext to remain available, got %d rows", plaintextRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
|
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
|
||||||
if recFetch2.Code != http.StatusBadRequest {
|
respFetch2 := assertOK(t, recFetch2)
|
||||||
t.Fatalf("second fetch should fail with 400, got %d body=%s", recFetch2.Code, recFetch2.Body.String())
|
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 fetchData2["password"] != initPassword {
|
||||||
if err := json.Unmarshal(recFetch2.Body.Bytes(), &respFetch2); err != nil {
|
t.Fatalf("expected second fetch password %s, got %v", initPassword, fetchData2["password"])
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token := adminLogin(t, e, testAdminUsername, initPassword)
|
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)
|
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)
|
recMe2 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||||
respMe2 := assertOK(t, recMe2)
|
respMe2 := assertOK(t, recMe2)
|
||||||
meData2, _ := respMe2["data"].(map[string]interface{})
|
meData2, _ := respMe2["data"].(map[string]interface{})
|
||||||
|
|||||||
Reference in New Issue
Block a user