增加图形验证码的功能

This commit is contained in:
2026-08-01 00:05:04 +08:00
parent 97450f0739
commit 4f00b83e86
15 changed files with 838 additions and 2 deletions

307
captcha/captcha.go Normal file
View File

@@ -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
}