From cabc29c18b88218c399a6bb7d506efeef4826d32 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 29 Aug 2026 16:20:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20Bell=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bell/server/app/admin/apis/captcha.go | 3 +- Bell/server/app/admin/router/sys_router.go | 3 + .../production_login_test.go | 195 ++++++++++++++++++ .../bell_production_login/run-postgres.ps1 | 83 ++++++++ 4 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 Bell/server/tests/bell_production_login/production_login_test.go create mode 100644 Bell/server/tests/bell_production_login/run-postgres.ps1 diff --git a/Bell/server/app/admin/apis/captcha.go b/Bell/server/app/admin/apis/captcha.go index e884e24..0c6c7a8 100644 --- a/Bell/server/app/admin/apis/captcha.go +++ b/Bell/server/app/admin/apis/captcha.go @@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) { e.Error(500, err, "服务初始化失败!") return } - id, b64s, answer, err := captcha.DriverDigitFunc() + id, b64s, _, err := captcha.DriverDigitFunc() if err != nil { e.Logger.Errorf("DriverDigitFunc error, %s", err.Error()) e.Error(500, err, "验证码获取失败") return } - e.Logger.Infof("DriverDigitFunc answer: %s", answer) e.Custom(gin.H{ "code": 200, "data": b64s, diff --git a/Bell/server/app/admin/router/sys_router.go b/Bell/server/app/admin/router/sys_router.go index 52c7cda..7660a23 100644 --- a/Bell/server/app/admin/router/sys_router.go +++ b/Bell/server/app/admin/router/sys_router.go @@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle } func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + systemAPI := apis.System{} + v1.GET("/captcha", systemAPI.GenerateCaptchaHandler) + api := apis.SysMenu{} v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) { diff --git a/Bell/server/tests/bell_production_login/production_login_test.go b/Bell/server/tests/bell_production_login/production_login_test.go new file mode 100644 index 0000000..499d4c5 --- /dev/null +++ b/Bell/server/tests/bell_production_login/production_login_test.go @@ -0,0 +1,195 @@ +package bell_production_login_test + +import ( + "bytes" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/config/source/file" + "github.com/go-admin-team/go-admin-core/sdk" + sdkapi "github.com/go-admin-team/go-admin-core/sdk/api" + "github.com/go-admin-team/go-admin-core/sdk/config" + "github.com/go-admin-team/go-admin-core/sdk/pkg/captcha" + "github.com/mojocn/base64Captcha" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + adminrouter "go-admin/app/admin/router" + bellrouter "go-admin/app/bell/router" + "go-admin/common/bellconfig" + "go-admin/common/database" + "go-admin/common/middleware" + "go-admin/common/storage" + ext "go-admin/config" +) + +type apiResponse struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + ID string `json:"id"` + Msg string `json:"msg"` + Token string `json:"token"` +} + +func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) { + if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" { + t.Skip("production login database is not configured") + } + if err := os.MkdirAll("temp/logs", 0o700); err != nil { + t.Fatal(err) + } + gin.SetMode(gin.TestMode) + config.ExtendConfig = &ext.ExtConfig + config.Setup(file.NewSource(file.WithPath("../../config/settings.yml"))) + if err := bellconfig.ApplyRequiredEnvironment(); err != nil { + t.Fatal(err) + } + if config.ApplicationConfig.Mode != "prod" { + t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode) + } + database.Setup() + storage.Setup() + + engine := gin.New() + sdk.Runtime.SetEngine(engine) + engine.Use(sdkapi.SetRequestLogger) + engine.Use(middleware.WithContextDb) + authMiddleware, err := middleware.AuthInit() + if err != nil { + t.Fatal(err) + } + adminrouter.InitSysRouter(engine, authMiddleware) + adminrouter.InitExamplesRouter(engine, authMiddleware) + bellrouter.InitRouter() + + captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "") + if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) { + t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data) + } + + username := os.Getenv("BELL_BOOTSTRAP_USERNAME") + password := os.Getenv("BELL_BOOTSTRAP_PASSWORD") + answer := "813907" + validID := "bell-138-valid" + if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil { + t.Fatal(err) + } + if !captcha.Verify(validID, answer, false) { + t.Fatal("known captcha was not stored") + } + login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "") + if login.Code != 200 || login.Token == "" { + t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg) + } + replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "") + if replay.Code == 200 { + t.Fatal("used captcha was accepted again") + } + + wrongID := "bell-138-wrong" + if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil { + t.Fatal(err) + } + wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "") + if wrong.Code == 200 { + t.Fatal("incorrect captcha was accepted") + } + consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "") + if consumed.Code == 200 { + t.Fatal("captcha used by a failed attempt was not consumed") + } + + unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "") + if unauthenticated.Code == 200 { + t.Fatal("unauthenticated Bell business API was accepted") + } + disabled := httptest.NewRecorder() + engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)) + if disabled.Code != http.StatusNotFound { + t.Fatalf("disabled default route returned HTTP %d", disabled.Code) + } + + assertSecretsAbsentFromAudit(t, password, answer, login.Token) + assertSecretsAbsentFromLogs(t, password, answer, login.Token) + if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) { + t.Fatal("generated captcha accepted a deliberately incorrect answer") + } +} + +func loginBody(username, password, id, answer string) map[string]string { + return map[string]string{"username": username, "password": password, "uuid": id, "code": answer} +} + +func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse { + t.Helper() + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + request := httptest.NewRequest(method, path, bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + var response apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String()) + } + return response +} + +func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) { + t.Helper() + db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + for _, table := range []string{"sys_login_log", "sys_opera_log"} { + for _, secret := range secrets { + var count int64 + query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?" + if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("secret leaked into %s", table) + } + } + } +} + +func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) { + t.Helper() + patterns := append([]string{"DriverDigitFunc answer:"}, secrets...) + err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, pattern := range patterns { + if pattern != "" && strings.Contains(string(content), pattern) { + t.Fatalf("sensitive value found in server log %s", path) + } + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } +} diff --git a/Bell/server/tests/bell_production_login/run-postgres.ps1 b/Bell/server/tests/bell_production_login/run-postgres.ps1 new file mode 100644 index 0000000..d276214 --- /dev/null +++ b/Bell/server/tests/bell_production_login/run-postgres.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param([string]$PostgresBin = 'D:\pgsql17\bin') + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +$started = $false +$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [guid]::NewGuid().ToString('N')) +$data = Join-Path $root 'postgres' +$log = Join-Path $root 'postgres.log' +$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Get-FreePort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Wait-ForPort([int]$Port) { + for ($attempt = 0; $attempt -lt 120; $attempt++) { + try { + $client = [Net.Sockets.TcpClient]::new() + $connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected + $client.Dispose() + if ($connected) { + return + } + } catch { + } + Start-Sleep -Milliseconds 250 + } + throw 'PostgreSQL did not start' +} + +New-Item -ItemType Directory -Path $root | Out-Null +$port = Get-FreePort +try { + foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { + throw "Missing $name" + } + } + & (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'initdb failed' } + $arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start" + Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null + Wait-ForPort $port + $started = $true + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_138 + if ($LASTEXITCODE -ne 0) { throw 'createdb failed' } + + $env:GOTOOLCHAIN = 'go1.26.5' + $env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_138 sslmode=disable" + $env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL + $env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') + $env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin' + $env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_HOST = '127.0.0.1' + $env:BELL_PORT = (Get-FreePort).ToString() + + Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue + Push-Location $serverRoot + try { + go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log') + if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" } + go test ./tests/bell_production_login -count=1 -v + if ($LASTEXITCODE -ne 0) { throw 'production login test failed' } + } finally { + Pop-Location + } + Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true' +} finally { + if ($started) { + & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log') + } + foreach ($name in @('BELL_DATABASE_URL', 'BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue +} -- 2.34.1