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)
This commit is contained in:
xiezhouwei 2026-06-05 13:57:13 +08:00
parent aa1c9559c3
commit b63f75ade4
11 changed files with 404 additions and 3 deletions

View File

@ -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 // 是否启用邮箱域名限制

42
controller/captcha.go Normal file
View File

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

View File

@ -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(),

View File

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

View File

@ -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":

View File

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

203
service/captcha.go Normal file
View File

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

View File

@ -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={<IconLock />}
/>
{captchaEnabled && (
<div style={{ marginBottom: '8px' }}>
<div
style={{
fontSize: '14px',
fontWeight: 500,
color: 'var(--semi-color-text-0, #1c1f23)',
marginBottom: '8px',
}}
>
{t('验证码')}
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
<div style={{ flex: 1 }}>
<Input
placeholder={t('请输入验证码')}
onChange={(value) => setCaptchaCode(value)}
/>
</div>
<div
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
height: '32px',
borderRadius: '4px',
overflow: 'hidden',
border: '1px solid var(--semi-color-border, #d9d9d9)',
flexShrink: 0,
}}
onClick={fetchCaptcha}
title={t('点击刷新验证码')}
>
{captchaImage ? (
<img
src={captchaImage}
alt='验证码'
style={{ height: '32px', width: 'auto', display: 'block' }}
/>
) : (
<span
style={{
padding: '0 12px',
fontSize: '12px',
color: '#999',
whiteSpace: 'nowrap',
}}
>
{captchaLoading ? '...' : t('点击获取')}
</span>
)}
</div>
</div>
</div>
)}
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='pt-4'>
<Checkbox

View File

@ -79,6 +79,7 @@ const SystemSetting = () => {
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 用户校验')}
</Form.Checkbox>
<Form.Checkbox
field='CaptchaEnabled'
noLabel
onChange={(e) =>
handleCheckboxChange('CaptchaEnabled', e)
}
>
{t('允许图片验证码校验')}
</Form.Checkbox>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Checkbox

View File

@ -926,6 +926,7 @@
"允许 AccountFilter 参数": "Allow AccountFilter parameter",
"允许 HTTP 协议图片请求(适用于自部署代理)": "Allow HTTP protocol image requests (for self-deployed proxies)",
"允许 Turnstile 用户校验": "Allow Turnstile user verification",
"允许图片验证码校验": "Allow image captcha verification",
"允许 inference_geo 透传": "Allow inference_geo Pass-through",
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
"允许 service_tier 透传": "Allow service_tier Pass-through",

View File

@ -474,6 +474,7 @@
"允许 safety_identifier 透传": "允许 safety_identifier 透传",
"允许 service_tier 透传": "允许 service_tier 透传",
"允许 Turnstile 用户校验": "允许 Turnstile 用户校验",
"允许图片验证码校验": "允许图片验证码校验",
"允许不安全的 OriginHTTP": "允许不安全的 OriginHTTP",
"允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",