fix full example login password flow

This commit is contained in:
杭州明婳科技
2026-07-08 19:36:43 +08:00
parent 89721132a1
commit 4ccbbfbf1b
4 changed files with 215 additions and 9 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。
- **M11 SSE 换行注入修复**`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost`ValidateStruct(nil)` 直接返回 nil。
- **M16 测试工具脚手架边界修复**`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache``MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。
- **M16 测试工具脚手架与示例闭环修复**`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache``MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
- **M13 cron handler panic 未 recover 崩进程**cron/cron.go):`RunTask``checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。
+10 -2
View File
@@ -24,7 +24,7 @@ go run ./examples/minimal
go run ./examples/full
```
启动后自动迁移 user 表。接口:
启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口:
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
@@ -39,4 +39,12 @@ curl -X POST http://localhost:8082/api/v1/login \
-d '{"username":"alice","password":"secret"}'
```
> 示例代码为演示用途,密码明文存储、未做参数校验,生产环境请使用 bcrypt 哈希密码并配合 `validation` 包校验入参。
创建用户示例:
```bash
curl -X POST http://localhost:8082/api/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"username":"bob","password":"s3cret"}'
```
> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。
+56 -6
View File
@@ -10,12 +10,16 @@
// GET /api/v1/users/:id (需 Authorization: Bearer <token>
// POST /api/v1/users (创建用户)
//
// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。
//
// 运行:
//
// go run ./examples/full
package main
import (
"context"
"errors"
"fmt"
"os"
@@ -26,6 +30,7 @@ import (
"github.com/EthanCodeCraft/xlgo-core/repository"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/EthanCodeCraft/xlgo-core/validation"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -34,7 +39,7 @@ import (
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;size:64" json:"username"`
Password string `gorm:"size:128" json:"-"` // 实际项目应存 bcrypt 哈希
Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端
UserType string `gorm:"size:32" json:"user_type"`
}
@@ -46,6 +51,12 @@ func main() {
xlgo.WithModels(&User{}),
xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()),
xlgo.WithModules(router.ModuleFunc(registerRoutes)),
xlgo.WithHook(xlgo.Hook{
Name: "seed-example-user",
OnInit: func(*xlgo.App) error {
return ensureExampleUser(context.Background())
},
}),
)
// 初始化 user repositoryApp.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿)
@@ -82,10 +93,13 @@ func login(c *gin.Context) {
return
}
// 示例简化:查询用户,不校验密码哈希
u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username)
if err != nil {
response.Fail(c, "用户不存在")
response.Fail(c, "用户名或密码错误")
return
}
if !validation.CheckPassword(u.Password, req.Password) {
response.Fail(c, "用户名或密码错误")
return
}
@@ -114,15 +128,51 @@ func getUser(c *gin.Context) {
}
func createUser(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
UserType string `json:"user_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, "参数错误")
return
}
u.UserType = "user"
hash, err := validation.HashPassword(req.Password)
if err != nil {
response.ServerError(c, "密码加密失败")
return
}
u := User{
Username: req.Username,
Password: hash,
UserType: req.UserType,
}
if u.UserType == "" {
u.UserType = "user"
}
if err := userRepo.Create(c.Request.Context(), &u); err != nil {
response.Fail(c, "创建失败: "+err.Error())
return
}
response.Success(c, u)
}
func ensureExampleUser(ctx context.Context) error {
_, err := userRepo.FindOne(ctx, "username = ?", "alice")
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
hash, err := validation.HashPassword("secret")
if err != nil {
return err
}
return userRepo.Create(ctx, &User{
Username: "alice",
Password: hash,
UserType: "user",
})
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/validation"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
database.RegisterDialect(database.DialectSpec{
Name: "sqlite_full_example_test",
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string {
return c.CustomDSN
},
})
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: "sqlite_full_example_test",
CustomDSN: filepath.Join(t.TempDir(), "full-example.db"),
},
JWT: config.JWTConfig{
Secret: "test-secret-for-full-example-at-least-32-bytes",
Expire: time.Hour,
RefreshExpire: 24 * time.Hour,
Issuer: "xlgo",
Algorithm: "HS256",
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("设置测试配置失败: %v", err)
}
mgr := database.NewManager(cfg)
if err := mgr.InitDB(cfg); err != nil {
t.Fatalf("初始化测试数据库失败: %v", err)
}
db := mgr.Master()
if err := db.AutoMigrate(&User{}); err != nil {
t.Fatalf("迁移示例用户表失败: %v", err)
}
prev := database.SwapDefaultManager(mgr)
t.Cleanup(func() {
current := database.SwapDefaultManager(prev)
if current != nil && current != prev {
_ = current.Close()
}
})
gin.SetMode(gin.TestMode)
r := gin.New()
registerRoutes(r.Group(""))
if err := ensureExampleUser(context.Background()); err != nil {
t.Fatalf("初始化示例用户失败: %v", err)
}
return r, db
}
func postJSON(t *testing.T, r http.Handler, path string, body string, token string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析响应 JSON 失败: %v, body=%s", err, rec.Body.String())
}
return rec.Code, payload
}
func tokenFromResponse(t *testing.T, payload map[string]any) string {
t.Helper()
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("响应缺少 data 对象: %#v", payload)
}
token, ok := data["token"].(string)
if !ok || strings.TrimSpace(token) == "" {
t.Fatalf("响应缺少 token: %#v", payload)
}
return token
}
func TestFullExampleLoginAndPasswordFlow(t *testing.T) {
r, db := setupExampleRouter(t)
var seeded User
if err := db.Where("username = ?", "alice").First(&seeded).Error; err != nil {
t.Fatalf("示例用户 alice 应在启动时初始化: %v", err)
}
if seeded.Password == "" || seeded.Password == "secret" {
t.Fatal("示例用户密码必须保存为 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(seeded.Password, "secret") {
t.Fatal("示例用户 alice/secret 应能通过密码校验")
}
status, wrong := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"wrong"}`, "")
if status != http.StatusOK {
t.Fatalf("业务失败模式下 HTTP 状态应为 200got %d", status)
}
if code, _ := wrong["code"].(float64); code == 0 {
t.Fatalf("错误密码应返回业务失败: %#v", wrong)
}
_, loginPayload := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"secret"}`, "")
token := tokenFromResponse(t, loginPayload)
_, createPayload := postJSON(t, r, "/api/v1/users", `{"username":"bob","password":"s3cret"}`, token)
if code, _ := createPayload["code"].(float64); code != 0 {
t.Fatalf("带 token 创建用户应成功: %#v", createPayload)
}
var bob User
if err := db.Where("username = ?", "bob").First(&bob).Error; err != nil {
t.Fatalf("创建后的用户应写入数据库: %v", err)
}
if bob.Password == "" || bob.Password == "s3cret" {
t.Fatal("创建用户时必须保存 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(bob.Password, "s3cret") {
t.Fatal("创建用户的密码哈希应能通过校验")
}
_, bobLogin := postJSON(t, r, "/api/v1/login", `{"username":"bob","password":"s3cret"}`, "")
_ = tokenFromResponse(t, bobLogin)
}