mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 02:06:16 +00:00
feat: return initial admin password from install flow
This commit is contained in:
@@ -55,8 +55,6 @@ 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 initial password API remains available until password change. ║")
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
@@ -35,12 +33,6 @@ type MeResponse struct {
|
|||||||
PasswordIsDefault bool `json:"password_is_default" example:"true"`
|
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.
|
// Login verifies credentials, stamps last_login_at, returns a signed JWT.
|
||||||
// @Summary Admin login
|
// @Summary Admin login
|
||||||
// @Description Verify credentials and return a signed JWT token
|
// @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
|
// GetInitialPasswordHash returns the initial-password fingerprint so the
|
||||||
// frontend can warn until the password is changed.
|
// frontend can warn until the password is changed.
|
||||||
// @Summary Get initial admin password hash
|
// @Summary Get initial admin password hash
|
||||||
|
|||||||
+144
-32
@@ -3,23 +3,25 @@
|
|||||||
// When the .env config file is absent (or has install=true) the HTTP start
|
// 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
|
// command calls RunInstallServer, which listens on the same address the main
|
||||||
// server will eventually use (default :8000) and mounts two JSON endpoints
|
// 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
|
// GET /api/install/defaults — default field values for the form
|
||||||
// POST /install — validate + write .env, then shut down
|
// POST /api/install — validate + initialize install state, then shut down
|
||||||
//
|
//
|
||||||
// The HTTP listen address is submitted as two separate fields (http_bind_addr
|
// 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
|
// 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
|
// 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.
|
// want to change the port without touching the bind address.
|
||||||
//
|
//
|
||||||
// Once the .env is written the install server stops and normal bootstrap
|
// Once install state is initialized and the .env is finalized with
|
||||||
// proceeds on the same port without a restart.
|
// install=false, the install server stops and normal bootstrap proceeds on the
|
||||||
|
// same port without a restart.
|
||||||
package install
|
package install
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -28,10 +30,15 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"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"
|
luluHttp "github.com/GMWalletApp/epusdt/util/http"
|
||||||
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultInstallAddr is the listen address used by the install API.
|
// 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"`
|
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.
|
// InstallDefaults returns sensible default values for the install form.
|
||||||
func InstallDefaults() InstallRequest {
|
func InstallDefaults() InstallRequest {
|
||||||
return InstallRequest{
|
return InstallRequest{
|
||||||
@@ -149,28 +164,20 @@ func (h *installHandler) GetDefaults(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, InstallDefaults())
|
return c.JSON(http.StatusOK, InstallDefaults())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit validates the install payload, writes the .env file, and signals
|
// Submit validates the install payload, writes the .env file, initializes the
|
||||||
// the install server to shut down so the main bootstrap can proceed.
|
// 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
|
// 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).
|
// the http_listen config key (e.g. 0.0.0.0:8000).
|
||||||
//
|
//
|
||||||
// @Summary Install — submit configuration
|
// @Summary Install — submit configuration
|
||||||
// @Description Validates the submitted configuration and writes the .env file.
|
// @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.
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// @Tags Install
|
// @Tags Install
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body InstallRequest true "Install configuration"
|
// @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 400 {object} map[string]string "error"
|
||||||
// @Failure 500 {object} map[string]string "error"
|
// @Failure 500 {object} map[string]string "error"
|
||||||
// @Router /api/install [post]
|
// @Router /api/install [post]
|
||||||
@@ -210,16 +217,26 @@ func (h *installHandler) Submit(c echo.Context) error {
|
|||||||
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
|
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()})
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() { close(h.done) }()
|
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)
|
// RunInstallServer starts the install UI and REST API on listenAddr
|
||||||
// under the /install path and blocks until the .env file has been written.
|
// (default :8000), then blocks until installation has been finalized.
|
||||||
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
|
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
|
||||||
func RunInstallServer(listenAddr, envFilePath string) {
|
func RunInstallServer(listenAddr, envFilePath string) {
|
||||||
if listenAddr == "" {
|
if listenAddr == "" {
|
||||||
@@ -264,13 +281,18 @@ var formControlledKeys = map[string]bool{
|
|||||||
"install": true,
|
"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
|
// 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
|
// install form are preserved from the existing file so that operator-specific
|
||||||
// settings (tg_bot_token, db_type, etc.) survive a re-install.
|
// settings (tg_bot_token, db_type, etc.) survive a re-install.
|
||||||
// Keys that the form controls (app_uri, http_listen, …) always use the
|
// Keys that the form controls (app_uri, http_listen, …) always use the
|
||||||
// submitted values.
|
// 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.
|
// Collect existing non-empty key→value pairs for non-form keys.
|
||||||
existingValues := map[string]string{}
|
existingValues := map[string]string{}
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
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.
|
// Render the template into a buffer first.
|
||||||
var buf bytes.Buffer
|
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)
|
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 {
|
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}}
|
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
|
||||||
@@ -360,5 +472,5 @@ order_notice_max_retry={{.OrderNoticeMaxRetry}}
|
|||||||
api_rate_url=
|
api_rate_url=
|
||||||
|
|
||||||
# Set to true to re-run the install wizard on next startup.
|
# Set to true to re-run the install wizard on next startup.
|
||||||
install=false
|
install={{.InstallValue}}
|
||||||
`))
|
`))
|
||||||
|
|||||||
+192
-31
@@ -10,9 +10,89 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/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) {
|
func TestInstallDefaults(t *testing.T) {
|
||||||
d := InstallDefaults()
|
d := InstallDefaults()
|
||||||
if d.AppName != "epusdt" {
|
if d.AppName != "epusdt" {
|
||||||
@@ -43,7 +123,7 @@ func TestWriteEnvFile(t *testing.T) {
|
|||||||
OrderExpirationTime: 15,
|
OrderExpirationTime: 15,
|
||||||
OrderNoticeMaxRetry: 3,
|
OrderNoticeMaxRetry: 3,
|
||||||
}
|
}
|
||||||
if err := writeEnvFile(path, req); err != nil {
|
if err := writeEnvFile(path, req, false); err != nil {
|
||||||
t.Fatalf("writeEnvFile: %v", err)
|
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()
|
dir := t.TempDir()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
|
||||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
e, h := newInstallTestAPI(envPath)
|
||||||
e := echo.New()
|
|
||||||
e.POST("/install", h.Submit)
|
|
||||||
|
|
||||||
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}`
|
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))
|
rec := submitInstallRequest(t, e, payload)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
e.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
// done channel should be closed after successful submit
|
body := decodeBody(t, rec)
|
||||||
select {
|
if got, _ := body["message"].(string); got == "" {
|
||||||
case <-h.done:
|
t.Fatalf("expected success message, got body=%v", body)
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("handler did not close done channel within timeout")
|
|
||||||
}
|
}
|
||||||
|
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)
|
data, err := os.ReadFile(envPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("env file not written: %v", err)
|
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") {
|
if !strings.Contains(content, "http_listen=0.0.0.0:8000") {
|
||||||
t.Errorf("env file missing http_listen; content:\n%s", content)
|
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) {
|
func TestInstallAPISubmitMissingURI(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
|
||||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
e, _ := newInstallTestAPI(envPath)
|
||||||
e := echo.New()
|
rec := submitInstallRequest(t, e, `{"app_name":"x"}`)
|
||||||
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)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
@@ -205,15 +373,8 @@ func TestInstallAPISubmitInvalidPort(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
|
||||||
h := &installHandler{envFilePath: envPath, done: make(chan struct{})}
|
e, _ := newInstallTestAPI(envPath)
|
||||||
e := echo.New()
|
rec := submitInstallRequest(t, e, `{"app_uri":"http://example.com","http_bind_port":99999}`)
|
||||||
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)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const (
|
|||||||
func CheckAdminJWT() echo.MiddlewareFunc {
|
func CheckAdminJWT() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
|
if ctx.Path() == "" {
|
||||||
|
return next(ctx)
|
||||||
|
}
|
||||||
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import (
|
|||||||
|
|
||||||
var once sync.Once
|
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
|
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
|
||||||
// the minimum static rows: chains, chain tokens, default settings, and
|
// the minimum static rows: chains, chain tokens, default settings, and
|
||||||
// a Telegram notification channel migrated from legacy settings keys.
|
// a Telegram notification channel migrated from legacy settings keys.
|
||||||
|
|||||||
@@ -40,39 +40,47 @@ type InitialAdminPasswordHashInfo struct {
|
|||||||
// can print it to the console. Idempotent — subsequent calls return
|
// can print it to the console. Idempotent — subsequent calls return
|
||||||
// ("", false, nil).
|
// ("", false, nil).
|
||||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
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
|
return "", false, err
|
||||||
}
|
}
|
||||||
var count int64
|
if created {
|
||||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
cacheInitialAdminPasswordState(password)
|
||||||
return "", false, err
|
|
||||||
}
|
}
|
||||||
if count > 0 {
|
return password, created, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func purgeDeletedInitialAdminPasswordPlain() error {
|
func purgeDeletedInitialAdminPasswordPlainTx(tx *gorm.DB) error {
|
||||||
return dao.Mdb.Unscoped().
|
return tx.Unscoped().
|
||||||
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
|
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
Delete(&mdb.Setting{}).Error
|
Delete(&mdb.Setting{}).Error
|
||||||
}
|
}
|
||||||
@@ -91,12 +99,20 @@ func HashInitialAdminPassword(plain string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initAdminPasswordState(plain string) error {
|
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)
|
hash := HashInitialAdminPassword(plain)
|
||||||
settings := []mdb.Setting{
|
settings := []mdb.Setting{
|
||||||
{
|
{
|
||||||
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||||
Value: plain, Type: mdb.SettingTypeString,
|
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,
|
Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyInitAdminPasswordHash,
|
||||||
@@ -115,18 +131,21 @@ func initAdminPasswordState(plain string) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, row := range settings {
|
for _, row := range settings {
|
||||||
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
if err := upsertSettingRow(tx, row); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep in-process cache coherent for the current process.
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheInitialAdminPasswordState(plain string) {
|
||||||
|
hash := HashInitialAdminPassword(plain)
|
||||||
settingsCacheMu.Lock()
|
settingsCacheMu.Lock()
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
settingsCache[mdb.SettingKeyInitAdminPasswordPlain] = plain
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
settingsCache[mdb.SettingKeyInitAdminPasswordHash] = hash
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "false"
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = "false"
|
||||||
settingsCacheMu.Unlock()
|
settingsCacheMu.Unlock()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
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
|
// UpdateAdminUserPassword rehashes and persists a new password. When the
|
||||||
// change succeeds, the stored initial-password plaintext is deleted so it can
|
// 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 {
|
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||||
hash, err := HashPassword(newPlain)
|
hash, err := HashPassword(newPlain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
clearedPlaintext := false
|
||||||
changedCacheValue := ""
|
changedCacheValue := ""
|
||||||
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
err = dao.Mdb.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&mdb.AdminUser{}).
|
if err := tx.Model(&mdb.AdminUser{}).
|
||||||
@@ -261,8 +281,18 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
return err
|
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
|
// 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
|
var initHashRow mdb.Setting
|
||||||
if err := tx.Model(&mdb.Setting{}).
|
if err := tx.Model(&mdb.Setting{}).
|
||||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordHash).
|
||||||
@@ -272,6 +302,12 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
}
|
}
|
||||||
initHash := strings.TrimSpace(initHashRow.Value)
|
initHash := strings.TrimSpace(initHashRow.Value)
|
||||||
if initHash == "" {
|
if initHash == "" {
|
||||||
|
if hasPlaintext {
|
||||||
|
if err := clearInitialAdminPasswordPlain(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clearedPlaintext = true
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,17 +329,22 @@ func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
|||||||
if err := clearInitialAdminPasswordPlain(tx); err != nil {
|
if err := clearInitialAdminPasswordPlain(tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
clearedPlaintext = true
|
||||||
changedCacheValue = changedValue
|
changedCacheValue = changedValue
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changedCacheValue != "" {
|
if clearedPlaintext || changedCacheValue != "" {
|
||||||
settingsCacheMu.Lock()
|
settingsCacheMu.Lock()
|
||||||
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
if clearedPlaintext {
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
delete(settingsCache, mdb.SettingKeyInitAdminPasswordPlain)
|
||||||
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
settingsCache[mdb.SettingKeyInitAdminPasswordFetched] = "true"
|
||||||
|
}
|
||||||
|
if changedCacheValue != "" {
|
||||||
|
settingsCache[mdb.SettingKeyInitAdminPasswordChanged] = changedCacheValue
|
||||||
|
}
|
||||||
settingsCacheMu.Unlock()
|
settingsCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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) {
|
func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -164,3 +213,55 @@ func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
|
|||||||
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -290,10 +290,9 @@ func TestAdminChangePassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAdminInitPasswordFlow verifies that the initial password stays readable
|
// TestAdminInitialPasswordMetadataFlow verifies that the public plaintext route
|
||||||
// until the admin password is changed, along with the hash-based
|
// is gone while the hash-based default-password detection flow still works.
|
||||||
// "password changed" detection flow.
|
func TestAdminInitialPasswordMetadataFlow(t *testing.T) {
|
||||||
func TestAdminInitPasswordFlow(t *testing.T) {
|
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
const initPassword = "init-pass-123456"
|
const initPassword = "init-pass-123456"
|
||||||
|
|
||||||
@@ -321,14 +320,11 @@ func TestAdminInitPasswordFlow(t *testing.T) {
|
|||||||
t.Fatalf("expected password_changed=false before change, got true")
|
t.Fatalf("expected password_changed=false before change, got true")
|
||||||
}
|
}
|
||||||
|
|
||||||
recFetch := doGet(e, "/admin/api/v1/auth/init-password")
|
token := adminLogin(t, e, testAdminUsername, initPassword)
|
||||||
respFetch := assertOK(t, recFetch)
|
|
||||||
fetchData, _ := respFetch["data"].(map[string]interface{})
|
recFetch := doGetAdmin(e, "/admin/api/v1/auth/init-password", token)
|
||||||
if fetchData["username"] != testAdminUsername {
|
if recFetch.Code != http.StatusNotFound {
|
||||||
t.Fatalf("expected username=%s, got %v", testAdminUsername, fetchData["username"])
|
t.Fatalf("expected removed init-password route to return 404, got %d body=%s", recFetch.Code, recFetch.Body.String())
|
||||||
}
|
|
||||||
if fetchData["password"] != initPassword {
|
|
||||||
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
|
|
||||||
}
|
}
|
||||||
var plaintextRows int64
|
var plaintextRows int64
|
||||||
if err := dao.Mdb.Unscoped().
|
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)
|
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)
|
recMe1 := doGetAdmin(e, "/admin/api/v1/auth/me", token)
|
||||||
respMe1 := assertOK(t, recMe1)
|
respMe1 := assertOK(t, recMe1)
|
||||||
meData1, _ := respMe1["data"].(map[string]interface{})
|
meData1, _ := respMe1["data"].(map[string]interface{})
|
||||||
@@ -372,21 +356,6 @@ func TestAdminInitPasswordFlow(t *testing.T) {
|
|||||||
if got, _ := hashData2["password_changed"].(bool); !got {
|
if got, _ := hashData2["password_changed"].(bool); !got {
|
||||||
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().
|
if err := dao.Mdb.Unscoped().
|
||||||
Model(&mdb.Setting{}).
|
Model(&mdb.Setting{}).
|
||||||
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
|
|
||||||
// Public (no JWT)
|
// Public (no JWT)
|
||||||
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
adminV1.POST("/auth/login", admin.Ctrl.Login)
|
||||||
adminV1.GET("/auth/init-password", admin.Ctrl.GetInitialPassword)
|
|
||||||
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
|
adminV1.GET("/auth/init-password-hash", admin.Ctrl.GetInitialPasswordHash)
|
||||||
|
|
||||||
// Authenticated
|
// Authenticated
|
||||||
|
|||||||
Reference in New Issue
Block a user