From b63f75ade40a83c94ea4327f0d5adb12f0f90747 Mon Sep 17 00:00:00 2001 From: xiezhouwei Date: Fri, 5 Jun 2026 13:57:13 +0800 Subject: [PATCH] feat: add captcha verification for login form - Add /api/captcha endpoint with captcha generation/verification service - Add captcha_enabled option in system settings (model/option.go) - Add captcha display and refresh logic in LoginForm - Fix captcha not showing on Chrome due to Semi UI Form.Input suffix incompatibility; refactored to standalone flex layout - Fix stale localStorage cache preventing captcha rendering on first visit by proactively fetching /api/status on LoginForm mount - Add captcha_enabled toggle in admin SystemSetting panel - Add i18n keys for captcha-related text (zh-CN, en) --- common/constants.go | 1 + controller/captcha.go | 42 ++++ controller/misc.go | 1 + controller/user.go | 23 +- model/option.go | 3 + router/api-router.go | 1 + service/captcha.go | 203 ++++++++++++++++++ web/src/components/auth/LoginForm.jsx | 120 ++++++++++- web/src/components/settings/SystemSetting.jsx | 11 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh-CN.json | 1 + 11 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 controller/captcha.go create mode 100644 service/captcha.go diff --git a/common/constants.go b/common/constants.go index bf28547..c0c3e34 100644 --- a/common/constants.go +++ b/common/constants.go @@ -49,6 +49,7 @@ var LinuxDOOAuthEnabled = false var WeChatAuthEnabled = false var TelegramOAuthEnabled = false var TurnstileCheckEnabled = false +var CaptchaEnabled = false var RegisterEnabled = true var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制 diff --git a/controller/captcha.go b/controller/captcha.go new file mode 100644 index 0000000..a105233 --- /dev/null +++ b/controller/captcha.go @@ -0,0 +1,42 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +// GetCaptcha 获取验证码图片 +// GET /api/captcha +func GetCaptcha(c *gin.Context) { + if !common.CaptchaEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码功能未启用", + }) + return + } + + id, question, base64PNG, err := service.GenerateMathCaptcha() + if err != nil { + common.SysError("failed to generate captcha: " + err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码生成失败,请重试", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "captcha_id": id, + "question": question, + "captcha_image": base64PNG, + }, + }) +} diff --git a/controller/misc.go b/controller/misc.go index 9c51f6a..6d40272 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -79,6 +79,7 @@ func GetStatus(c *gin.Context) { "server_address": system_setting.ServerAddress, "turnstile_check": common.TurnstileCheckEnabled, "turnstile_site_key": common.TurnstileSiteKey, + "captcha_enabled": common.CaptchaEnabled, "top_up_link": common.TopUpLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink, "default_site_language": operation_setting.GetDefaultSiteLanguage(), diff --git a/controller/user.go b/controller/user.go index 837026b..1bc06bb 100644 --- a/controller/user.go +++ b/controller/user.go @@ -27,8 +27,10 @@ import ( ) type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` } // RegisterRequest 用户注册请求体:关闭短信注册时邮箱必填;开启短信注册时邮箱与手机号二选一(至少填其一);开启邮箱验证且填写了邮箱时需验证码;开启短信且填写了手机号时需短信验证码。邮箱/手机号占用仅与未注销用户冲突。 @@ -94,6 +96,23 @@ func Login(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } + // 验证码校验 + if common.CaptchaEnabled { + if loginRequest.CaptchaID == "" || loginRequest.CaptchaCode == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请输入验证码", + }) + return + } + if !service.VerifyCaptcha(loginRequest.CaptchaID, loginRequest.CaptchaCode) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码错误或已过期,请刷新后重试", + }) + return + } + } user := model.User{ Username: username, Password: password, diff --git a/model/option.go b/model/option.go index 164d086..d60d0cb 100644 --- a/model/option.go +++ b/model/option.go @@ -43,6 +43,7 @@ func InitOptionMap() { common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) + common.OptionMap["CaptchaEnabled"] = strconv.FormatBool(common.CaptchaEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) @@ -343,6 +344,8 @@ func updateOptionMap(key string, value string) (err error) { common.TelegramOAuthEnabled = boolValue case "TurnstileCheckEnabled": common.TurnstileCheckEnabled = boolValue + case "CaptchaEnabled": + common.CaptchaEnabled = boolValue case "SMSVerificationEnabled": common.SMSVerificationEnabled = boolValue case "RegisterEnabled": diff --git a/router/api-router.go b/router/api-router.go index d71c13d..021d351 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -42,6 +42,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.POST("/price_sync", middleware.CriticalRateLimit(), controller.PriceSync) + apiRouter.GET("/captcha", controller.GetCaptcha) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/sms_verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendSMSVerification) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) diff --git a/service/captcha.go b/service/captcha.go new file mode 100644 index 0000000..b741bdb --- /dev/null +++ b/service/captcha.go @@ -0,0 +1,203 @@ +package service + +import ( + "image" + "image/color" + "image/draw" + "image/png" + "math/rand" + "bytes" + "encoding/base64" + "fmt" + "sync" + "time" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +// CaptchaItem 表示一个验证码条目 +type CaptchaItem struct { + ID string + Answer string + ExpiresAt time.Time +} + +var ( + captchaStore = sync.Map{} // map[string]*CaptchaItem + captchaCleaner sync.Once +) + +func initCaptchaCleaner() { + go func() { + for { + time.Sleep(5 * time.Minute) + now := time.Now() + captchaStore.Range(func(key, value interface{}) bool { + item := value.(*CaptchaItem) + if now.After(item.ExpiresAt) { + captchaStore.Delete(key) + } + return true + }) + } + }() +} + +// GenerateMathCaptcha 生成一个数学验证码,返回 id、问题表达式、base64 PNG 图片 +func GenerateMathCaptcha() (id string, question string, base64PNG string, err error) { + captchaCleaner.Do(initCaptchaCleaner) + + // 生成随机数学题 + a := rand.Intn(20) + 1 // 1-20 + b := rand.Intn(20) + 1 // 1-20 + op := rand.Intn(2) // 0: +, 1: - + var answer int + var opStr string + if op == 0 { + answer = a + b + opStr = "+" + } else { + // 确保结果非负 + if a < b { + a, b = b, a + } + answer = a - b + opStr = "-" + } + + question = fmt.Sprintf("%d %s %d = ?", a, opStr, b) + answerStr := fmt.Sprintf("%d", answer) + + img, err := drawCaptchaImage(question) + if err != nil { + return "", "", "", err + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", "", "", err + } + + base64PNG = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) + + // 生成唯一 ID 并存储 + id = fmt.Sprintf("%d%06d", time.Now().UnixNano(), rand.Intn(1000000)) + captchaStore.Store(id, &CaptchaItem{ + ID: id, + Answer: answerStr, + ExpiresAt: time.Now().Add(5 * time.Minute), + }) + + return id, question, base64PNG, nil +} + +// VerifyCaptcha 验证验证码,验证后立即使其失效 +func VerifyCaptcha(id string, answer string) bool { + if id == "" || answer == "" { + return false + } + val, ok := captchaStore.LoadAndDelete(id) + if !ok { + return false + } + item := val.(*CaptchaItem) + if time.Now().After(item.ExpiresAt) { + return false + } + return item.Answer == answer +} + +// drawCaptchaImage 将验证码文字绘制为带噪点的图片 +func drawCaptchaImage(text string) (*image.RGBA, error) { + width := 160 + height := 50 + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // 随机浅色背景 + bgR := uint8(220 + rand.Intn(36)) + bgG := uint8(220 + rand.Intn(36)) + bgB := uint8(220 + rand.Intn(36)) + bgColor := color.RGBA{bgR, bgG, bgB, 255} + draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src) + + // 绘制干扰线 + for i := 0; i < 4; i++ { + x1 := rand.Intn(width) + y1 := rand.Intn(height) + x2 := rand.Intn(width) + y2 := rand.Intn(height) + lineColor := color.RGBA{ + uint8(rand.Intn(150)), + uint8(rand.Intn(150)), + uint8(rand.Intn(150)), + 255, + } + drawLine(img, x1, y1, x2, y2, lineColor) + } + + // 绘制干扰点 + for i := 0; i < 60; i++ { + x := rand.Intn(width) + y := rand.Intn(height) + dotColor := color.RGBA{ + uint8(rand.Intn(200)), + uint8(rand.Intn(200)), + uint8(rand.Intn(200)), + 255, + } + img.Set(x, y, dotColor) + } + + // 绘制文字 + face := basicfont.Face7x13 + d := &font.Drawer{ + Dst: img, + Src: &image.Uniform{color.RGBA{50, 50, 50, 255}}, + Face: face, + } + + // 计算文字宽度以居中 + textWidth := font.MeasureString(face, text).Ceil() + startX := (width - textWidth) / 2 + if startX < 5 { + startX = 5 + } + + d.Dot = fixed.P(startX, 30) + d.DrawString(text) + + return img, nil +} + +// drawLine 在图像上绘制一条线段(Bresenham 算法简化版) +func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) { + dx := abs(x2 - x1) + dy := abs(y2 - y1) + steps := dx + if dy > dx { + steps = dy + } + if steps == 0 { + img.Set(x1, y1, c) + return + } + for i := 0; i <= steps; i++ { + x := x1 + (x2-x1)*i/steps + y := y1 + (y2-y1)*i/steps + // 线宽为 2 + img.Set(x, y, c) + if x+1 < img.Bounds().Max.X { + img.Set(x+1, y, c) + } + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 0bc56dd..a5227d3 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -31,6 +31,7 @@ import { getSystemName, getOAuthProviderIcon, setUserData, + setStatusData, onGitHubOAuthClicked, onDiscordOAuthClicked, onOIDCClicked, @@ -50,6 +51,7 @@ import { Divider, Form, Icon, + Input, Modal, } from '@douyinfe/semi-ui'; import Title from '@douyinfe/semi-ui/lib/es/typography/title'; @@ -97,7 +99,7 @@ const LoginForm = () => { }; const [submitted, setSubmitted] = useState(false); const [userState, userDispatch] = useContext(UserContext); - const [statusState] = useContext(StatusContext); + const [statusState, statusDispatch] = useContext(StatusContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); @@ -122,6 +124,11 @@ const LoginForm = () => { const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false); const [githubButtonState, setGithubButtonState] = useState('idle'); const [githubButtonDisabled, setGithubButtonDisabled] = useState(false); + const [captchaEnabled, setCaptchaEnabled] = useState(false); + const [captchaId, setCaptchaId] = useState(''); + const [captchaCode, setCaptchaCode] = useState(''); + const [captchaImage, setCaptchaImage] = useState(''); + const [captchaLoading, setCaptchaLoading] = useState(false); const githubTimeoutRef = useRef(null); const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]); const [customOAuthLoading, setCustomOAuthLoading] = useState({}); @@ -165,8 +172,34 @@ const LoginForm = () => { // 从 status 获取用户协议和隐私政策的启用状态 setHasUserAgreement(status?.user_agreement_enabled || false); setHasPrivacyPolicy(status?.privacy_policy_enabled || false); + + // 验证码状态 + if (status?.captcha_enabled) { + setCaptchaEnabled(true); + } }, [status]); + // 登录页挂载时主动拉取最新 status,避免 localStorage 缓存的旧数据 + // 缺少 captcha_enabled 字段导致验证码不显示 + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await API.get('/api/status'); + const { success, data } = res.data; + if (!cancelled && success && data && typeof data === 'object') { + statusDispatch({ type: 'set', payload: data }); + setStatusData(data); + } + } catch { + // 请求失败时继续使用 Context / localStorage 回退 + } + })(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { isPasskeySupported() .then(setPasskeySupported) @@ -185,6 +218,29 @@ const LoginForm = () => { } }, []); + useEffect(() => { + if (captchaEnabled) { + fetchCaptcha(); + } + }, [captchaEnabled]); + + const fetchCaptcha = async () => { + setCaptchaLoading(true); + try { + const res = await API.get('/api/captcha', { skipErrorHandler: true }); + const { success, data } = res.data; + if (success && data) { + setCaptchaId(data.captcha_id); + setCaptchaImage(data.captcha_image); + } + } catch (error) { + // silently fail; user can click refresh to retry + setCaptchaImage(''); + } finally { + setCaptchaLoading(false); + } + }; + const onWeChatLoginClicked = () => { if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { showInfo(t('请先阅读并同意用户协议和隐私政策')); @@ -246,6 +302,8 @@ const LoginForm = () => { { username, password, + captcha_id: captchaEnabled ? captchaId : undefined, + captcha_code: captchaEnabled ? captchaCode : undefined, }, ); const { success, message, data } = res.data; @@ -511,6 +569,10 @@ const LoginForm = () => { const handleBackToLogin = () => { setShowTwoFA(false); setInputs({ username: '', password: '', wechat_verification_code: '' }); + setCaptchaCode(''); + if (captchaEnabled) { + fetchCaptcha(); + } }; const renderOAuthOptions = () => { @@ -777,6 +839,62 @@ const LoginForm = () => { prefix={} /> + {captchaEnabled && ( +
+
+ {t('验证码')} +
+
+
+ setCaptchaCode(value)} + /> +
+
+ {captchaImage ? ( + 验证码 + ) : ( + + {captchaLoading ? '...' : t('点击获取')} + + )} +
+
+
+ )} + {(hasUserAgreement || hasPrivacyPolicy) && (
{ WeChatServerToken: '', WeChatAccountQRCodeImageURL: '', TurnstileCheckEnabled: '', + CaptchaEnabled: '', TurnstileSiteKey: '', TurnstileSecretKey: '', SMSVerificationEnabled: '', @@ -211,6 +212,7 @@ const SystemSetting = () => { case 'TelegramOAuthEnabled': case 'RegisterEnabled': case 'TurnstileCheckEnabled': + case 'CaptchaEnabled': case 'EmailDomainRestrictionEnabled': case 'EmailAliasRestrictionEnabled': case 'SMTPSSLEnabled': @@ -1180,6 +1182,15 @@ const SystemSetting = () => { > {t('允许 Turnstile 用户校验')} + + handleCheckboxChange('CaptchaEnabled', e) + } + > + {t('允许图片验证码校验')} +