fix full example login password flow
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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 repository(App.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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 状态应为 200,got %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)
|
||||
}
|
||||
Reference in New Issue
Block a user