From f1044b68d174610051319a2d44ef60442d17fdc4 Mon Sep 17 00:00:00 2001 From: ila Date: Sat, 22 Aug 2026 09:10:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E7=AB=AF=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=B4=A6=E5=8F=B7=E5=AF=86=E7=A0=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/app/admin/router/sys_router_test.go | 27 ++++ .../auth_login_mysql_integration_test.go | 127 ++++++++++++++++++ admin/common/middleware/handler/auth.go | 15 +-- admin/common/middleware/handler/auth_test.go | 97 +++++++++++++ admin/common/middleware/handler/login.go | 2 - 5 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 admin/app/admin/router/sys_router_test.go create mode 100644 admin/common/middleware/auth_login_mysql_integration_test.go create mode 100644 admin/common/middleware/handler/auth_test.go diff --git a/admin/app/admin/router/sys_router_test.go b/admin/app/admin/router/sys_router_test.go new file mode 100644 index 0000000..7c23172 --- /dev/null +++ b/admin/app/admin/router/sys_router_test.go @@ -0,0 +1,27 @@ +package router + +import ( + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func TestSystemRouterExposesLoginWithoutCaptcha(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + InitSysRouter(engine, &jwt.GinJWTMiddleware{}) + + routes := make(map[string]struct{}) + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + if _, ok := routes["POST /api/v1/login"]; !ok { + t.Fatal("password login route is not registered") + } + for _, path := range []string{"GET /api/v1/captcha", "GET /api/v1/getCaptcha"} { + if _, ok := routes[path]; ok { + t.Fatalf("captcha route must not be registered: %s", path) + } + } +} diff --git a/admin/common/middleware/auth_login_mysql_integration_test.go b/admin/common/middleware/auth_login_mysql_integration_test.go new file mode 100644 index 0000000..e4978f5 --- /dev/null +++ b/admin/common/middleware/auth_login_mysql_integration_test.go @@ -0,0 +1,127 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/config" + mysqldriver "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestPasswordOnlyLoginHandlerMySQL(t *testing.T) { + db := openLoginIntegrationDB(t) + username := fmt.Sprintf("login-test-%d", time.Now().UnixNano()) + password := "synthetic-login-password" + seedLoginIntegrationUser(t, db, username, password) + t.Cleanup(func() { + if err := db.Exec(`DELETE FROM sys_user WHERE username = ?`, username).Error; err != nil { + t.Errorf("delete login test user: %v", err) + } + }) + + originalApplication := *config.ApplicationConfig + originalJWT := *config.JwtConfig + originalLogger := *config.LoggerConfig + config.ApplicationConfig.Mode = "prod" + config.JwtConfig.Secret = "integration-test-jwt-secret" + config.JwtConfig.Timeout = 60 + config.LoggerConfig.EnabledDB = false + t.Cleanup(func() { + *config.ApplicationConfig = originalApplication + *config.JwtConfig = originalJWT + *config.LoggerConfig = originalLogger + }) + + auth, err := AuthInit() + if err != nil { + t.Fatalf("initialize test JWT middleware: %v", err) + } + engine := gin.New() + engine.POST("/api/v1/login", func(c *gin.Context) { + c.Set("db", db) + c.Next() + }, auth.LoginHandler) + + response := loginRequest(t, engine, fmt.Sprintf(`{"username":%q,"password":%q}`, username, password)) + if response.Code != http.StatusOK || response.Token == "" { + t.Fatal("production password-only login did not return an authenticated response") + } + + for _, payload := range []string{ + fmt.Sprintf(`{"username":%q}`, username), + fmt.Sprintf(`{"username":%q,"password":"wrong-password"}`, username), + } { + response := loginRequest(t, engine, payload) + if response.Code == http.StatusOK || response.Token != "" { + t.Fatal("invalid password-only login was not rejected") + } + } +} + +type loginHTTPResponse struct { + Code int `json:"code"` + Token string `json:"token"` +} + +func loginRequest(t *testing.T, engine *gin.Engine, payload string) loginHTTPResponse { + t.Helper() + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewBufferString(payload)) + request.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(recorder, request) + var response loginHTTPResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode login response: %v", err) + } + return response +} + +func openLoginIntegrationDB(t *testing.T) *gorm.DB { + t.Helper() + if os.Getenv("CHORUS_RUN_ADMIN_TESTS") != "1" { + t.Skip("set CHORUS_RUN_ADMIN_TESTS=1 to run against the disposable MySQL test database") + } + dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN")) + if dsn == "" { + t.Fatal("CHORUS_DSN is required for login integration tests") + } + parsed, err := mysqldriver.ParseDSN(dsn) + if err != nil { + t.Fatalf("parse MySQL DSN: %v", err) + } + if parsed.DBName != "chorus_test" { + t.Fatalf("refusing to use non-disposable database %q", parsed.DBName) + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open login integration database: %v", err) + } + return db +} + +func seedLoginIntegrationUser(t *testing.T, db *gorm.DB, username, password string) { + t.Helper() + var roleID int64 + if err := db.Raw(`SELECT role_id FROM sys_role WHERE role_key = 'chorus_operator' AND status = '2' AND deleted_at IS NULL`).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("find active chorus operator role: %v", err) + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash login test password: %v", err) + } + if err := db.Exec(`INSERT INTO sys_user (username, password, role_id, status) VALUES (?, ?, ?, '2')`, username, string(hash), roleID).Error; err != nil { + t.Fatalf("seed login test user: %v", err) + } +} diff --git a/admin/common/middleware/handler/auth.go b/admin/common/middleware/handler/auth.go index 6f831cc..e0fb427 100644 --- a/admin/common/middleware/handler/auth.go +++ b/admin/common/middleware/handler/auth.go @@ -5,17 +5,16 @@ import ( "git.ilapage.cn/OPC/chorus/admin/common" "net/http" + "git.ilapage.cn/OPC/chorus/admin/common/global" "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/sdk" "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" - "github.com/go-admin-team/go-admin-core/sdk/pkg/captcha" jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" "github.com/go-admin-team/go-admin-core/sdk/pkg/response" "github.com/mssola/user_agent" - "git.ilapage.cn/OPC/chorus/admin/common/global" ) func PayloadFunc(data interface{}) jwt.MapClaims { @@ -52,8 +51,7 @@ func IdentityHandler(c *gin.Context) interface{} { // @Description LoginHandler can be used by clients to get a jwt token. // @Description Payload needs to be json in the form of {"username": "USERNAME", "password": "PASSWORD"}. // @Description Reply will be of the form {"token": "TOKEN"}. -// @Description dev mode:It should be noted that all fields cannot be empty, and a value of 0 can be passed in addition to the account password -// @Description 注意:开发模式:需要注意全部字段不能为空,账号密码外可以传入0值 +// @Description The request requires only username and password in every application mode. // @Tags 登陆 // @Accept application/json // @Product application/json @@ -84,15 +82,6 @@ func Authenticator(c *gin.Context) (interface{}, error) { return nil, jwt.ErrMissingLoginValues } - if config.ApplicationConfig.Mode != "dev" { - if !captcha.Verify(loginVals.UUID, loginVals.Code, true) { - username = loginVals.Username - msg = "验证码错误" - status = "1" - - return nil, jwt.ErrInvalidVerificationode - } - } sysUser, role, e := loginVals.GetUser(db) if e == nil { username = loginVals.Username diff --git a/admin/common/middleware/handler/auth_test.go b/admin/common/middleware/handler/auth_test.go new file mode 100644 index 0000000..204016c --- /dev/null +++ b/admin/common/middleware/handler/auth_test.go @@ -0,0 +1,97 @@ +package handler + +import ( + "bytes" + "errors" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/config" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAuthenticatorAllowsPasswordOnlyInProduction(t *testing.T) { + db := loginTestDB(t) + seedLoginUser(t, db, "operator", "correct-password") + + originalMode := config.ApplicationConfig.Mode + config.ApplicationConfig.Mode = "prod" + t.Cleanup(func() { config.ApplicationConfig.Mode = originalMode }) + + identity, err := authenticateLoginRequest(t, db, `{"username":"operator","password":"correct-password"}`) + if err != nil { + t.Fatalf("authenticate production password-only login: %v", err) + } + claims, ok := identity.(map[string]interface{}) + if !ok || claims["user"] == nil || claims["role"] == nil { + t.Fatalf("unexpected authenticated identity: %#v", identity) + } +} + +func TestAuthenticatorRejectsMissingAndIncorrectPassword(t *testing.T) { + db := loginTestDB(t) + seedLoginUser(t, db, "operator", "correct-password") + + for _, test := range []struct { + name string + payload string + want error + }{ + {name: "missing password", payload: `{"username":"operator"}`, want: jwt.ErrMissingLoginValues}, + {name: "wrong password", payload: `{"username":"operator","password":"wrong-password"}`, want: jwt.ErrFailedAuthentication}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := authenticateLoginRequest(t, db, test.payload) + if !errors.Is(err, test.want) { + t.Fatalf("authentication error = %v, want %v", err, test.want) + } + }) + } +} + +func authenticateLoginRequest(t *testing.T, db *gorm.DB, payload string) (interface{}, error) { + t.Helper() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + request := httptest.NewRequest("POST", "/api/v1/login", bytes.NewBufferString(payload)) + request.Header.Set("Content-Type", "application/json") + context.Request = request + context.Set("db", db) + return Authenticator(context) +} + +func loginTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open login test database: %v", err) + } + for _, statement := range []string{ + `CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, role_name TEXT NOT NULL, status TEXT NOT NULL, role_key TEXT NOT NULL, data_scope TEXT NOT NULL, deleted_at DATETIME NULL)`, + `CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, username TEXT NOT NULL, password TEXT NOT NULL, role_id INTEGER NOT NULL, status TEXT NOT NULL, deleted_at DATETIME NULL)`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create login test schema: %v", err) + } + } + return db +} + +func seedLoginUser(t *testing.T, db *gorm.DB, username, password string) { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash test password: %v", err) + } + if err := db.Exec(`INSERT INTO sys_role (role_id, role_name, status, role_key, data_scope) VALUES (1, 'operator', '2', 'chorus_operator', 'all')`).Error; err != nil { + t.Fatalf("seed login role: %v", err) + } + if err := db.Exec(`INSERT INTO sys_user (user_id, username, password, role_id, status) VALUES (1, ?, ?, 1, '2')`, username, string(hash)).Error; err != nil { + t.Fatalf("seed login user: %v", err) + } +} diff --git a/admin/common/middleware/handler/login.go b/admin/common/middleware/handler/login.go index d17626c..3358b43 100644 --- a/admin/common/middleware/handler/login.go +++ b/admin/common/middleware/handler/login.go @@ -9,8 +9,6 @@ import ( type Login struct { Username string `form:"UserName" json:"username" binding:"required"` Password string `form:"Password" json:"password" binding:"required"` - Code string `form:"Code" json:"code" binding:"required"` - UUID string `form:"UUID" json:"uuid" binding:"required"` } func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {