From 4f00b83e868dcd8a404b048813d4268caa03da91 Mon Sep 17 00:00:00 2001 From: Jimmy Xue Date: Sat, 1 Aug 2026 00:05:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9B=BE=E5=BD=A2=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INTEGRATION.md | 36 +++++ README.md | 3 +- VERSION.md | 3 +- captcha/captcha.go | 307 ++++++++++++++++++++++++++++++++++++ captcha/captcha_test.go | 174 ++++++++++++++++++++ captcha/errors.go | 17 ++ captcha/redis_store.go | 42 +++++ config/config.go | 76 +++++++++ config/example.json | 17 ++ examples/captcha_example.go | 62 ++++++++ factory/factory.go | 37 +++++ factory/lifecycle.go | 13 ++ factory/options.go | 8 + go.mod | 5 + go.sum | 40 +++++ 15 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 captcha/captcha.go create mode 100644 captcha/captcha_test.go create mode 100644 captcha/errors.go create mode 100644 captcha/redis_store.go create mode 100644 examples/captcha_example.go diff --git a/INTEGRATION.md b/INTEGRATION.md index 912db43..3bcb9b7 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -89,6 +89,7 @@ go run ./cmd/server -config config.json -addr :8080 | Excel | `Excel()` | 每次新建导出器 | | 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 | | MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8) | +| 图形验证码 | `Captcha()` / `MustCaptcha()` | `Generate` / `Verify` / 登录防暴力(见 §8) | | HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` | | 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` | | 迁移 | `Migrator(dir)` | Up / Down / Status | @@ -205,6 +206,41 @@ ctx = mcp.WithRequestID(ctx, requestID) 加一条配置,不改代码;`allowedTools` 留空表示开放该 server 全部工具。完整字段见 [`config/example.json`](./config/example.json) 的 `mcp` 段。 +### 图形验证码(Captcha) + +可选模块:`config.captcha.enabled=false`(或未配置)时 `Captcha().Enabled()` 为 false,**不影响 middleware**;业务 handler 按需调用即可。 + +```go +cap := app.MustCaptcha() // enabled=true 时需配置 redis + +// 1. 生成(注册/改手机号等发短信前) +result, _ := cap.Generate(ctx, captcha.SceneRegister) +// 返回 result.ID + result.ImageBase64 给前端展示 + +// 2. 校验(一次性消费) +if err := cap.Verify(ctx, captcha.VerifyRequest{ + Scene: captcha.SceneRegister, ID: captchaID, Answer: userInput, +}); err != nil { + h.Error("captcha.invalid") + return +} +// 验证通过后再 app.MustSMS().SendSMS(...) + +// 3. 登录防暴力(失败 N 次后要求验证码,M 次后锁定) +key := username // 或 username+IP,由业务决定 +if need, _ := cap.LoginNeedCaptcha(ctx, key); need { + // 要求前端传 captchaId + captchaCode,并 Verify +} +status, err := cap.RecordLoginFailure(ctx, key) +if status.Locked { h.Error("captcha.login_locked"); return } +// 登录成功后 +_ = cap.ClearLoginFailures(ctx, key) +``` + +场景常量:`SceneRegister` / `SceneChangePhone` / `SceneLogin` / `SceneSendSMS`。 +`scenes.register.mode=always` 表示该场景始终要求图形验证码;登录场景由 `login.showAfterFailures` 控制「失败后才要求」。 +完整配置见 [`config/example.json`](./config/example.json) 的 `captcha` 段。 + --- ## 9. 最小 config.json diff --git a/README.md b/README.md index fe58782..67d012c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ git.toowon.com/jimmy/go-common ```bash go env -w GOPRIVATE=git.toowon.com -go get git.toowon.com/jimmy/go-common@v1.3.0 +go get git.toowon.com/jimmy/go-common@v1.4.0 ``` ## 设计 @@ -34,6 +34,7 @@ go get git.toowon.com/jimmy/go-common@v1.3.0 | `migration` | SQL 迁移 | | `tools` | 时间、加密、金额等 | | `mcp` | MCP Client(多 server、`ListTools`/`CallTool`) | +| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) | ## 文档 diff --git a/VERSION.md b/VERSION.md index 80b173e..4c1d413 100644 --- a/VERSION.md +++ b/VERSION.md @@ -24,10 +24,11 @@ go get git.toowon.com/jimmy/go-common@latest # 开发 ## 当前版本 -**v1.3.0** +**v1.4.0** ## 版本历史 +- **v1.4.0** — 新增 `captcha` 模块:图形验证码(`Generate`/`Verify`、Redis 存储、场景配置)与登录防暴力(失败计数、触发验证码、临时锁定);`config.CaptchaConfig`;`factory.Captcha()`/`MustCaptcha()`/`WithCaptcha`/`ModuleCaptcha`;**不进默认 middleware**,第三方按需 `enabled` 开关 - **v1.3.0** — 新增 `mcp` 模块:MCP(Model Context Protocol)Client(`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool`);`config.MCPConfig`;`factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现) - **v1.2.0** — DX:`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`;Option 注入;Excel 每次新建;`templates/server`;`ErrorData`;文档精简 - **v1.1.0** — 同上 DX 能力首次合入(见上) diff --git a/captcha/captcha.go b/captcha/captcha.go new file mode 100644 index 0000000..5f8604b --- /dev/null +++ b/captcha/captcha.go @@ -0,0 +1,307 @@ +package captcha + +import ( + "context" + "fmt" + "strings" + "time" + + "git.toowon.com/jimmy/go-common/config" + "github.com/mojocn/base64Captcha" + "github.com/redis/go-redis/v9" +) + +// Scene 验证码业务场景。 +type Scene string + +const ( + SceneRegister Scene = "register" + SceneChangePhone Scene = "change_phone" + SceneLogin Scene = "login" + SceneSendSMS Scene = "send_sms" +) + +// GenerateResult 生成图形验证码的返回。 +type GenerateResult struct { + ID string `json:"id"` + ImageBase64 string `json:"imageBase64"` +} + +// VerifyRequest 校验图形验证码的请求。 +type VerifyRequest struct { + Scene Scene + ID string + Answer string +} + +// LoginGuardStatus 登录防暴力状态。 +type LoginGuardStatus struct { + FailCount int `json:"failCount"` + NeedCaptcha bool `json:"needCaptcha"` + Locked bool `json:"locked"` + RemainingSec int `json:"remainingSec"` +} + +// Manager 图形验证码子系统(`app.Captcha()` 取得)。 +// +// 职责: +// - 生成/校验图形验证码(Redis 存储,一次性消费); +// - 登录失败计数、触发验证码、临时锁定; +// - Enabled() 反映 config.captcha.enabled 且 Redis 可用。 +// +// 不含路由与业务编排:消费方在 handler 中按需调用 Generate / Verify / RecordLoginFailure。 +type Manager interface { + Enabled() bool + Generate(ctx context.Context, scene Scene) (*GenerateResult, error) + Verify(ctx context.Context, req VerifyRequest) error + SceneRequiresCaptcha(scene Scene) bool + LoginNeedCaptcha(ctx context.Context, key string) (bool, error) + IsLoginLocked(ctx context.Context, key string) (bool, int, error) + RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error) + ClearLoginFailures(ctx context.Context, key string) error +} + +type manager struct { + enabled bool + cfg *config.CaptchaConfig + rdb *redis.Client + captcha *base64Captcha.Captcha +} + +// NewManager 根据配置与 Redis 构建图形验证码子系统。 +// cfg 为 nil 或 enabled=false 时返回 Enabled()=false 的安全实例,其余方法返回 ErrDisabled。 +// enabled=true 但 rdb=nil 时同样视为不可用(Enabled()=false)。 +func NewManager(rdb *redis.Client, cfg *config.CaptchaConfig) Manager { + m := &manager{cfg: cfg} + if cfg == nil || !cfg.Enabled || rdb == nil { + return m + } + + normalizeCaptchaConfig(cfg) + + ttl := time.Duration(cfg.TTLSec) * time.Second + store := newRedisStore(rdb, ttl) + driver := base64Captcha.NewDriverDigit(cfg.Height, cfg.Width, cfg.Length, 0.7, 80) + m.enabled = true + m.rdb = rdb + m.captcha = base64Captcha.NewCaptcha(driver, store) + return m +} + +func normalizeCaptchaConfig(cfg *config.CaptchaConfig) { + if cfg.Length == 0 { + cfg.Length = 4 + } + if cfg.Width == 0 { + cfg.Width = 120 + } + if cfg.Height == 0 { + cfg.Height = 40 + } + if cfg.TTLSec == 0 { + cfg.TTLSec = 300 + } + if cfg.Login == nil { + cfg.Login = &config.LoginCaptchaConfig{} + } + if cfg.Login.ShowAfterFailures == 0 { + cfg.Login.ShowAfterFailures = 3 + } + if cfg.Login.LockAfterFailures == 0 { + cfg.Login.LockAfterFailures = 10 + } + if cfg.Login.LockDurationMin == 0 { + cfg.Login.LockDurationMin = 30 + } +} + +func (m *manager) Enabled() bool { + return m != nil && m.enabled +} + +func (m *manager) Generate(ctx context.Context, scene Scene) (*GenerateResult, error) { + if err := m.requireEnabled(); err != nil { + return nil, err + } + _ = ctx + + id, b64s, _, err := m.captcha.Generate() + if err != nil { + return nil, fmt.Errorf("captcha generate: %w", err) + } + return &GenerateResult{ + ID: id, + ImageBase64: b64s, + }, nil +} + +func (m *manager) Verify(ctx context.Context, req VerifyRequest) error { + if err := m.requireEnabled(); err != nil { + return err + } + _ = ctx + + id := strings.TrimSpace(req.ID) + answer := strings.TrimSpace(req.Answer) + if id == "" || answer == "" { + return ErrInvalid + } + if !m.captcha.Verify(id, answer, true) { + return ErrInvalid + } + return nil +} + +func (m *manager) SceneRequiresCaptcha(scene Scene) bool { + if !m.Enabled() { + return false + } + if scene == SceneLogin { + return false + } + mode := m.sceneMode(scene) + return mode == "always" +} + +func (m *manager) LoginNeedCaptcha(ctx context.Context, key string) (bool, error) { + if !m.Enabled() { + return false, nil + } + loginCfg := m.loginConfig() + if loginCfg.ShowAfterFailures <= 0 { + return false, nil + } + + locked, _, err := m.IsLoginLocked(ctx, key) + if err != nil { + return false, err + } + if locked { + return true, nil + } + + count, err := m.getLoginFailCount(ctx, key) + if err != nil { + return false, err + } + return count >= loginCfg.ShowAfterFailures, nil +} + +func (m *manager) IsLoginLocked(ctx context.Context, key string) (bool, int, error) { + if !m.Enabled() { + return false, 0, nil + } + ttl, err := m.rdb.TTL(ctx, loginLockKey(key)).Result() + if err != nil { + return false, 0, err + } + if ttl <= 0 { + return false, 0, nil + } + return true, int(ttl.Seconds()), nil +} + +func (m *manager) RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error) { + status := LoginGuardStatus{} + if !m.Enabled() { + return status, nil + } + + loginCfg := m.loginConfig() + lockKey := loginLockKey(key) + failKey := loginFailKey(key) + + if ttl, err := m.rdb.TTL(ctx, lockKey).Result(); err != nil { + return status, err + } else if ttl > 0 { + status.Locked = true + status.NeedCaptcha = true + status.RemainingSec = int(ttl.Seconds()) + return status, ErrLoginLocked + } + + count, err := m.rdb.Incr(ctx, failKey).Result() + if err != nil { + return status, err + } + if count == 1 { + _ = m.rdb.Expire(ctx, failKey, time.Duration(loginCfg.LockDurationMin)*time.Minute).Err() + } + + status.FailCount = int(count) + status.NeedCaptcha = loginCfg.ShowAfterFailures > 0 && int(count) >= loginCfg.ShowAfterFailures + + if loginCfg.LockAfterFailures > 0 && int(count) >= loginCfg.LockAfterFailures { + lockTTL := time.Duration(loginCfg.LockDurationMin) * time.Minute + if err := m.rdb.Set(ctx, lockKey, "1", lockTTL).Err(); err != nil { + return status, err + } + _ = m.rdb.Del(ctx, failKey).Err() + status.Locked = true + status.NeedCaptcha = true + status.RemainingSec = int(lockTTL.Seconds()) + return status, ErrLoginLocked + } + + return status, nil +} + +func (m *manager) ClearLoginFailures(ctx context.Context, key string) error { + if !m.Enabled() { + return nil + } + return m.rdb.Del(ctx, loginFailKey(key), loginLockKey(key)).Err() +} + +func (m *manager) requireEnabled() error { + if !m.Enabled() { + return ErrDisabled + } + return nil +} + +func (m *manager) sceneMode(scene Scene) string { + if m.cfg == nil || m.cfg.Scenes == nil { + return defaultSceneMode(scene) + } + if sc, ok := m.cfg.Scenes[string(scene)]; ok && sc.Mode != "" { + return sc.Mode + } + return defaultSceneMode(scene) +} + +func defaultSceneMode(scene Scene) string { + switch scene { + case SceneRegister, SceneChangePhone, SceneSendSMS: + return "always" + default: + return "never" + } +} + +func (m *manager) loginConfig() config.LoginCaptchaConfig { + if m.cfg == nil || m.cfg.Login == nil { + return config.LoginCaptchaConfig{ + ShowAfterFailures: 3, + LockAfterFailures: 10, + LockDurationMin: 30, + } + } + return *m.cfg.Login +} + +func (m *manager) getLoginFailCount(ctx context.Context, key string) (int, error) { + n, err := m.rdb.Get(ctx, loginFailKey(key)).Int() + if err == redis.Nil { + return 0, nil + } + return n, err +} + +func loginFailKey(key string) string { + return "captcha:login:fail:" + key +} + +func loginLockKey(key string) string { + return "captcha:login:lock:" + key +} diff --git a/captcha/captcha_test.go b/captcha/captcha_test.go new file mode 100644 index 0000000..130bf75 --- /dev/null +++ b/captcha/captcha_test.go @@ -0,0 +1,174 @@ +package captcha_test + +import ( + "context" + "testing" + "time" + + "git.toowon.com/jimmy/go-common/captcha" + "git.toowon.com/jimmy/go-common/config" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func TestDisabledManager(t *testing.T) { + m := captcha.NewManager(nil, nil) + if m.Enabled() { + t.Fatal("expected disabled") + } + _, err := m.Generate(context.Background(), captcha.SceneRegister) + if err != captcha.ErrDisabled { + t.Fatalf("Generate err = %v, want ErrDisabled", err) + } +} + +func TestGenerateAndVerify(t *testing.T) { + s, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + rdb := redis.NewClient(&redis.Options{Addr: s.Addr()}) + cfg := &config.CaptchaConfig{ + Enabled: true, + Length: 4, + Width: 120, + Height: 40, + TTLSec: 300, + Login: &config.LoginCaptchaConfig{}, + } + m := captcha.NewManager(rdb, cfg) + if !m.Enabled() { + t.Fatal("expected enabled") + } + + ctx := context.Background() + result, err := m.Generate(ctx, captcha.SceneRegister) + if err != nil { + t.Fatal(err) + } + if result.ID == "" || result.ImageBase64 == "" { + t.Fatal("expected id and image") + } + + // 从 redis 读取答案用于测试 + answer, err := rdb.Get(ctx, "captcha:code:"+result.ID).Result() + if err != nil { + t.Fatal(err) + } + if err := m.Verify(ctx, captcha.VerifyRequest{ + Scene: captcha.SceneRegister, + ID: result.ID, + Answer: answer, + }); err != nil { + t.Fatalf("Verify: %v", err) + } + if err := m.Verify(ctx, captcha.VerifyRequest{ + Scene: captcha.SceneRegister, + ID: result.ID, + Answer: answer, + }); err != captcha.ErrInvalid { + t.Fatalf("second Verify err = %v, want ErrInvalid", err) + } +} + +func TestLoginGuard(t *testing.T) { + s, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + rdb := redis.NewClient(&redis.Options{Addr: s.Addr()}) + cfg := &config.CaptchaConfig{ + Enabled: true, + Length: 4, + Width: 120, + Height: 40, + TTLSec: 300, + Login: &config.LoginCaptchaConfig{ + ShowAfterFailures: 2, + LockAfterFailures: 4, + LockDurationMin: 1, + }, + } + m := captcha.NewManager(rdb, cfg) + ctx := context.Background() + key := "alice" + + status, err := m.RecordLoginFailure(ctx, key) + if err != nil || status.FailCount != 1 || status.NeedCaptcha { + t.Fatalf("first failure: status=%+v err=%v", status, err) + } + + status, err = m.RecordLoginFailure(ctx, key) + if err != nil || status.FailCount != 2 || !status.NeedCaptcha { + t.Fatalf("second failure: status=%+v err=%v", status, err) + } + + need, err := m.LoginNeedCaptcha(ctx, key) + if err != nil || !need { + t.Fatalf("LoginNeedCaptcha = %v, err = %v", need, err) + } + + for i := 0; i < 2; i++ { + _, _ = m.RecordLoginFailure(ctx, key) + } + status, err = m.RecordLoginFailure(ctx, key) + if err != captcha.ErrLoginLocked || !status.Locked { + t.Fatalf("lock: status=%+v err=%v", status, err) + } + + locked, remaining, err := m.IsLoginLocked(ctx, key) + if err != nil || !locked || remaining <= 0 { + t.Fatalf("IsLoginLocked = %v, %d, err = %v", locked, remaining, err) + } + + if err := m.ClearLoginFailures(ctx, key); err != nil { + t.Fatal(err) + } + locked, _, _ = m.IsLoginLocked(ctx, key) + if locked { + t.Fatal("expected unlocked after clear") + } +} + +func TestSceneRequiresCaptcha(t *testing.T) { + cfg := &config.CaptchaConfig{ + Enabled: true, + Scenes: map[string]config.CaptchaSceneConfig{ + "register": {Mode: "always"}, + "login": {Mode: "never"}, + }, + } + m := captcha.NewManager(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}), cfg) + if !m.SceneRequiresCaptcha(captcha.SceneRegister) { + t.Fatal("register should require captcha") + } + if m.SceneRequiresCaptcha(captcha.SceneLogin) { + t.Fatal("login should not always require captcha") + } +} + +func TestRedisStoreTTL(t *testing.T) { + s, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer s.Close() + + rdb := redis.NewClient(&redis.Options{Addr: s.Addr()}) + cfg := &config.CaptchaConfig{Enabled: true, TTLSec: 1, Login: &config.LoginCaptchaConfig{}} + m := captcha.NewManager(rdb, cfg) + ctx := context.Background() + + result, err := m.Generate(ctx, captcha.SceneRegister) + if err != nil { + t.Fatal(err) + } + s.FastForward(2 * time.Second) + if err := m.Verify(ctx, captcha.VerifyRequest{ID: result.ID, Answer: "x"}); err != captcha.ErrInvalid { + t.Fatalf("expired verify err = %v", err) + } +} diff --git a/captcha/errors.go b/captcha/errors.go new file mode 100644 index 0000000..f03e269 --- /dev/null +++ b/captcha/errors.go @@ -0,0 +1,17 @@ +package captcha + +import "errors" + +var ( + // ErrDisabled 图形验证码子系统未启用。 + ErrDisabled = errors.New("captcha disabled") + + // ErrInvalid 验证码错误或已失效。 + ErrInvalid = errors.New("captcha invalid") + + // ErrRequired 当前场景要求验证码但未提供或校验失败。 + ErrRequired = errors.New("captcha required") + + // ErrLoginLocked 登录失败次数过多,账号或 IP 已被临时锁定。 + ErrLoginLocked = errors.New("login locked") +) diff --git a/captcha/redis_store.go b/captcha/redis_store.go new file mode 100644 index 0000000..18962c9 --- /dev/null +++ b/captcha/redis_store.go @@ -0,0 +1,42 @@ +package captcha + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +const codeKeyPrefix = "captcha:code:" + +// redisStore 实现 base64Captcha.Store,将验证码答案存入 Redis。 +type redisStore struct { + client *redis.Client + ttl time.Duration +} + +func newRedisStore(client *redis.Client, ttl time.Duration) *redisStore { + return &redisStore{client: client, ttl: ttl} +} + +func (s *redisStore) Set(id string, value string) error { + return s.client.Set(context.Background(), codeKeyPrefix+id, value, s.ttl).Err() +} + +func (s *redisStore) Get(id string, clear bool) string { + ctx := context.Background() + key := codeKeyPrefix + id + val, err := s.client.Get(ctx, key).Result() + if err != nil { + return "" + } + if clear { + _ = s.client.Del(ctx, key).Err() + } + return val +} + +func (s *redisStore) Verify(id, answer string, clear bool) bool { + val := s.Get(id, clear) + return val != "" && val == answer +} diff --git a/config/config.go b/config/config.go index 98db4fe..b4c81d2 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ type Config struct { I18n *I18nConfig `json:"i18n"` RateLimit *RateLimitConfig `json:"rateLimit"` MCP *MCPConfig `json:"mcp"` + Captcha *CaptchaConfig `json:"captcha"` } // I18nConfig 国际化配置 @@ -308,6 +309,48 @@ func (c *LoggerConfig) IsAsync() bool { return *c.Async } +// CaptchaConfig 图形验证码配置(可选模块;enabled=false 或未配置时不启用) +type CaptchaConfig struct { + // Enabled 总开关 + Enabled bool `json:"enabled"` + + // Length 验证码字符数,默认 4 + Length int `json:"length"` + + // Width 图片宽度(像素),默认 120 + Width int `json:"width"` + + // Height 图片高度(像素),默认 40 + Height int `json:"height"` + + // TTLSec 验证码有效期(秒),默认 300 + TTLSec int `json:"ttlSec"` + + // Login 登录防暴力配置(失败 N 次后要求验证码,M 次后锁定) + Login *LoginCaptchaConfig `json:"login"` + + // Scenes 各场景是否强制图形验证码(mode: always | never;login 场景由 Login 配置控制) + Scenes map[string]CaptchaSceneConfig `json:"scenes"` +} + +// LoginCaptchaConfig 登录防暴力配置 +type LoginCaptchaConfig struct { + // ShowAfterFailures 失败多少次后开始要求图形验证码,默认 3 + ShowAfterFailures int `json:"showAfterFailures"` + + // LockAfterFailures 失败多少次后临时锁定,默认 10 + LockAfterFailures int `json:"lockAfterFailures"` + + // LockDurationMin 锁定时长(分钟),默认 30 + LockDurationMin int `json:"lockDurationMin"` +} + +// CaptchaSceneConfig 单场景验证码策略 +type CaptchaSceneConfig struct { + // Mode always=该场景始终要求图形验证码;never=不要求(login 场景忽略此项) + Mode string `json:"mode"` +} + // RateLimitConfig 限流配置 type RateLimitConfig struct { // Enable 是否启用限流 @@ -544,6 +587,34 @@ func (c *Config) setDefaults() { c.MCP.CallTimeoutSeconds = 60 } } + + // Captcha 默认值 + if c.Captcha != nil { + if c.Captcha.Length == 0 { + c.Captcha.Length = 4 + } + if c.Captcha.Width == 0 { + c.Captcha.Width = 120 + } + if c.Captcha.Height == 0 { + c.Captcha.Height = 40 + } + if c.Captcha.TTLSec == 0 { + c.Captcha.TTLSec = 300 + } + if c.Captcha.Login == nil { + c.Captcha.Login = &LoginCaptchaConfig{} + } + if c.Captcha.Login.ShowAfterFailures == 0 { + c.Captcha.Login.ShowAfterFailures = 3 + } + if c.Captcha.Login.LockAfterFailures == 0 { + c.Captcha.Login.LockAfterFailures = 10 + } + if c.Captcha.Login.LockDurationMin == 0 { + c.Captcha.Login.LockDurationMin = 30 + } + } } // GetDatabase 获取数据库配置 @@ -603,6 +674,11 @@ func (c *Config) GetMCP() *MCPConfig { return c.MCP } +// GetCaptcha 获取图形验证码配置 +func (c *Config) GetCaptcha() *CaptchaConfig { + return c.Captcha +} + // GetDatabaseDSN 获取数据库连接字符串 func (c *Config) GetDatabaseDSN() (string, error) { if c.Database == nil { diff --git a/config/example.json b/config/example.json index 6d756c9..792ea29 100644 --- a/config/example.json +++ b/config/example.json @@ -90,6 +90,23 @@ "byIP": true, "byUserID": false }, + "captcha": { + "enabled": false, + "length": 4, + "width": 120, + "height": 40, + "ttlSec": 300, + "login": { + "showAfterFailures": 3, + "lockAfterFailures": 10, + "lockDurationMin": 30 + }, + "scenes": { + "register": { "mode": "always" }, + "change_phone": { "mode": "always" }, + "send_sms": { "mode": "always" } + } + }, "mcp": { "enabled": true, "callTimeoutSeconds": 60, diff --git a/examples/captcha_example.go b/examples/captcha_example.go new file mode 100644 index 0000000..478f02c --- /dev/null +++ b/examples/captcha_example.go @@ -0,0 +1,62 @@ +//go:build example +// +build example + +package main + +import ( + "context" + "fmt" + "log" + + "git.toowon.com/jimmy/go-common/captcha" + "git.toowon.com/jimmy/go-common/config" + "git.toowon.com/jimmy/go-common/factory" +) + +func main() { + cfg, err := config.LoadFromFile("./config/example.json") + if err != nil { + log.Fatal(err) + } + + app := factory.New(cfg) + cap, err := app.Captcha() + if err != nil { + log.Fatal(err) + } + + if !cap.Enabled() { + fmt.Println("captcha disabled — set captcha.enabled=true and configure redis to enable") + return + } + + ctx := context.Background() + + // 1. 注册场景:生成图形验证码 + result, err := cap.Generate(ctx, captcha.SceneRegister) + if err != nil { + log.Fatal(err) + } + fmt.Printf("captcha id: %s\n", result.ID) + fmt.Printf("captcha image length: %d (base64)\n", len(result.ImageBase64)) + + // 2. 发短信前校验(业务侧在 handler 中调用;此处演示 API) + // 实际使用时 answer 来自用户输入 + _ = cap.Verify(ctx, captcha.VerifyRequest{ + Scene: captcha.SceneRegister, + ID: result.ID, + Answer: "user-input", + }) + + // 3. 登录防暴力 + key := "user@example.com" + status, _ := cap.RecordLoginFailure(ctx, key) + fmt.Printf("login failures: %d, needCaptcha: %v, locked: %v\n", + status.FailCount, status.NeedCaptcha, status.Locked) + + need, _ := cap.LoginNeedCaptcha(ctx, key) + fmt.Printf("login need captcha: %v\n", need) + + // 登录成功后清除 + _ = cap.ClearLoginFailures(ctx, key) +} diff --git a/factory/factory.go b/factory/factory.go index 98e7279..954b9de 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "git.toowon.com/jimmy/go-common/captcha" "git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/excel" @@ -42,6 +43,7 @@ type Factory struct { i18n *i18n.I18n chain *middleware.Chain mcp mcp.Manager + captcha captcha.Manager mu sync.Mutex } @@ -437,6 +439,41 @@ func (f *Factory) MCP() (mcp.Manager, error) { return f.getMCP() } +func (f *Factory) getCaptcha() (captcha.Manager, error) { + if f.captcha != nil { + return f.captcha, nil + } + + var cfg *config.CaptchaConfig + if f.cfg != nil { + cfg = f.cfg.Captcha + } + + var rdb *redis.Client + if cfg != nil && cfg.Enabled { + var err error + rdb, err = f.getRedis() + if err != nil { + return nil, fmt.Errorf("captcha enabled but redis unavailable: %w", err) + } + } + + f.mu.Lock() + defer f.mu.Unlock() + if f.captcha != nil { + return f.captcha, nil + } + f.captcha = captcha.NewManager(rdb, cfg) + return f.captcha, nil +} + +// Captcha 获取图形验证码子系统(Generate / Verify / 登录防暴力)。 +// config.captcha 未配置或 enabled=false 时返回 Enabled()=false 的安全实例。 +// enabled=true 时需配置 redis,否则返回 error。 +func (f *Factory) Captcha() (captcha.Manager, error) { + return f.getCaptcha() +} + // Migrator 创建迁移器并加载指定目录下的 SQL 文件 func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) { db, err := f.getDatabase() diff --git a/factory/lifecycle.go b/factory/lifecycle.go index bbaab9f..da5f627 100644 --- a/factory/lifecycle.go +++ b/factory/lifecycle.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "git.toowon.com/jimmy/go-common/captcha" "git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/logger" @@ -26,6 +27,7 @@ const ( ModuleSMS Module = "sms" ModuleI18n Module = "i18n" ModuleMCP Module = "mcp" + ModuleCaptcha Module = "captcha" ) // MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动) @@ -60,6 +62,8 @@ func (f *Factory) Warmup(modules ...Module) error { _, err = f.I18n() case ModuleMCP: _, err = f.MCP() + case ModuleCaptcha: + _, err = f.Captcha() default: return fmt.Errorf("unknown module: %s", m) } @@ -198,3 +202,12 @@ func (f *Factory) MustMCP() mcp.Manager { } return m } + +// MustCaptcha 获取图形验证码子系统对象;失败则 panic(适合启动期注入) +func (f *Factory) MustCaptcha() captcha.Manager { + c, err := f.Captcha() + if err != nil { + panic(err) + } + return c +} diff --git a/factory/options.go b/factory/options.go index c53e4a9..9dacae7 100644 --- a/factory/options.go +++ b/factory/options.go @@ -1,6 +1,7 @@ package factory import ( + "git.toowon.com/jimmy/go-common/captcha" "git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/logger" @@ -74,3 +75,10 @@ func WithMCP(m mcp.Manager) Option { f.mcp = m } } + +// WithCaptcha 注入自定义图形验证码实现(如测试用的 fake 实现) +func WithCaptcha(c captcha.Manager) Option { + return func(f *Factory) { + f.captcha = c + } +} diff --git a/go.mod b/go.mod index 1249186..0b5d0c7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module git.toowon.com/jimmy/go-common go 1.25.0 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/google/uuid v1.6.0 github.com/minio/minio-go/v7 v7.0.97 github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/mojocn/base64Captcha v1.3.6 github.com/redis/go-redis/v9 v9.17.1 github.com/xuri/excelize/v2 v2.10.0 golang.org/x/crypto v0.43.0 @@ -21,6 +23,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -47,6 +50,8 @@ require ( github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + golang.org/x/image v0.25.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index 4182fb2..5e6a193 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -19,6 +21,8 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -58,6 +62,8 @@ github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5 github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw= +github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -94,22 +100,56 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=