Compare commits

...
Author SHA1 Message Date
QiuSW cabc29c18b fix: 恢复 Bell 生产验证码登录 (#138) 2026-08-29 16:20:34 +08:00
ila 452cd71035 Merge PR #137: Bell 预警处置生命周期
关联 #133;用户验收后关闭工单。
2026-08-29 11:56:57 +08:00
4 changed files with 282 additions and 2 deletions
+1 -2
View File
@@ -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,
@@ -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())
{
@@ -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)
}
}
@@ -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
}