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={