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:
parent
aa1c9559c3
commit
b63f75ade4
|
|
@ -49,6 +49,7 @@ var LinuxDOOAuthEnabled = false
|
||||||
var WeChatAuthEnabled = false
|
var WeChatAuthEnabled = false
|
||||||
var TelegramOAuthEnabled = false
|
var TelegramOAuthEnabled = false
|
||||||
var TurnstileCheckEnabled = false
|
var TurnstileCheckEnabled = false
|
||||||
|
var CaptchaEnabled = false
|
||||||
var RegisterEnabled = true
|
var RegisterEnabled = true
|
||||||
|
|
||||||
var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
|
var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,7 @@ func GetStatus(c *gin.Context) {
|
||||||
"server_address": system_setting.ServerAddress,
|
"server_address": system_setting.ServerAddress,
|
||||||
"turnstile_check": common.TurnstileCheckEnabled,
|
"turnstile_check": common.TurnstileCheckEnabled,
|
||||||
"turnstile_site_key": common.TurnstileSiteKey,
|
"turnstile_site_key": common.TurnstileSiteKey,
|
||||||
|
"captcha_enabled": common.CaptchaEnabled,
|
||||||
"top_up_link": common.TopUpLink,
|
"top_up_link": common.TopUpLink,
|
||||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||||
"default_site_language": operation_setting.GetDefaultSiteLanguage(),
|
"default_site_language": operation_setting.GetDefaultSiteLanguage(),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import (
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
CaptchaID string `json:"captcha_id"`
|
||||||
|
CaptchaCode string `json:"captcha_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRequest 用户注册请求体:关闭短信注册时邮箱必填;开启短信注册时邮箱与手机号二选一(至少填其一);开启邮箱验证且填写了邮箱时需验证码;开启短信且填写了手机号时需短信验证码。邮箱/手机号占用仅与未注销用户冲突。
|
// RegisterRequest 用户注册请求体:关闭短信注册时邮箱必填;开启短信注册时邮箱与手机号二选一(至少填其一);开启邮箱验证且填写了邮箱时需验证码;开启短信且填写了手机号时需短信验证码。邮箱/手机号占用仅与未注销用户冲突。
|
||||||
|
|
@ -94,6 +96,23 @@ func Login(c *gin.Context) {
|
||||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||||
return
|
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{
|
user := model.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ func InitOptionMap() {
|
||||||
common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
|
common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
|
||||||
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
|
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
|
||||||
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
|
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
|
||||||
|
common.OptionMap["CaptchaEnabled"] = strconv.FormatBool(common.CaptchaEnabled)
|
||||||
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
|
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
|
||||||
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
|
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
|
||||||
common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled)
|
common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled)
|
||||||
|
|
@ -343,6 +344,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||||
common.TelegramOAuthEnabled = boolValue
|
common.TelegramOAuthEnabled = boolValue
|
||||||
case "TurnstileCheckEnabled":
|
case "TurnstileCheckEnabled":
|
||||||
common.TurnstileCheckEnabled = boolValue
|
common.TurnstileCheckEnabled = boolValue
|
||||||
|
case "CaptchaEnabled":
|
||||||
|
common.CaptchaEnabled = boolValue
|
||||||
case "SMSVerificationEnabled":
|
case "SMSVerificationEnabled":
|
||||||
common.SMSVerificationEnabled = boolValue
|
common.SMSVerificationEnabled = boolValue
|
||||||
case "RegisterEnabled":
|
case "RegisterEnabled":
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||||
apiRouter.POST("/price_sync", middleware.CriticalRateLimit(), controller.PriceSync)
|
apiRouter.POST("/price_sync", middleware.CriticalRateLimit(), controller.PriceSync)
|
||||||
|
apiRouter.GET("/captcha", controller.GetCaptcha)
|
||||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||||
apiRouter.GET("/sms_verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendSMSVerification)
|
apiRouter.GET("/sms_verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendSMSVerification)
|
||||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
getSystemName,
|
getSystemName,
|
||||||
getOAuthProviderIcon,
|
getOAuthProviderIcon,
|
||||||
setUserData,
|
setUserData,
|
||||||
|
setStatusData,
|
||||||
onGitHubOAuthClicked,
|
onGitHubOAuthClicked,
|
||||||
onDiscordOAuthClicked,
|
onDiscordOAuthClicked,
|
||||||
onOIDCClicked,
|
onOIDCClicked,
|
||||||
|
|
@ -50,6 +51,7 @@ import {
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Icon,
|
Icon,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
|
|
@ -97,7 +99,7 @@ const LoginForm = () => {
|
||||||
};
|
};
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
const [statusState] = useContext(StatusContext);
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
|
@ -122,6 +124,11 @@ const LoginForm = () => {
|
||||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
const [githubButtonState, setGithubButtonState] = useState('idle');
|
||||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
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 githubTimeoutRef = useRef(null);
|
||||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
||||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||||
|
|
@ -165,8 +172,34 @@ const LoginForm = () => {
|
||||||
// 从 status 获取用户协议和隐私政策的启用状态
|
// 从 status 获取用户协议和隐私政策的启用状态
|
||||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||||
|
|
||||||
|
// 验证码状态
|
||||||
|
if (status?.captcha_enabled) {
|
||||||
|
setCaptchaEnabled(true);
|
||||||
|
}
|
||||||
}, [status]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
isPasskeySupported()
|
isPasskeySupported()
|
||||||
.then(setPasskeySupported)
|
.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 = () => {
|
const onWeChatLoginClicked = () => {
|
||||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||||
|
|
@ -246,6 +302,8 @@ const LoginForm = () => {
|
||||||
{
|
{
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
captcha_id: captchaEnabled ? captchaId : undefined,
|
||||||
|
captcha_code: captchaEnabled ? captchaCode : undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
|
|
@ -511,6 +569,10 @@ const LoginForm = () => {
|
||||||
const handleBackToLogin = () => {
|
const handleBackToLogin = () => {
|
||||||
setShowTwoFA(false);
|
setShowTwoFA(false);
|
||||||
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
||||||
|
setCaptchaCode('');
|
||||||
|
if (captchaEnabled) {
|
||||||
|
fetchCaptcha();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOAuthOptions = () => {
|
const renderOAuthOptions = () => {
|
||||||
|
|
@ -777,6 +839,62 @@ const LoginForm = () => {
|
||||||
prefix={<IconLock />}
|
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) && (
|
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||||
<div className='pt-4'>
|
<div className='pt-4'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ const SystemSetting = () => {
|
||||||
WeChatServerToken: '',
|
WeChatServerToken: '',
|
||||||
WeChatAccountQRCodeImageURL: '',
|
WeChatAccountQRCodeImageURL: '',
|
||||||
TurnstileCheckEnabled: '',
|
TurnstileCheckEnabled: '',
|
||||||
|
CaptchaEnabled: '',
|
||||||
TurnstileSiteKey: '',
|
TurnstileSiteKey: '',
|
||||||
TurnstileSecretKey: '',
|
TurnstileSecretKey: '',
|
||||||
SMSVerificationEnabled: '',
|
SMSVerificationEnabled: '',
|
||||||
|
|
@ -211,6 +212,7 @@ const SystemSetting = () => {
|
||||||
case 'TelegramOAuthEnabled':
|
case 'TelegramOAuthEnabled':
|
||||||
case 'RegisterEnabled':
|
case 'RegisterEnabled':
|
||||||
case 'TurnstileCheckEnabled':
|
case 'TurnstileCheckEnabled':
|
||||||
|
case 'CaptchaEnabled':
|
||||||
case 'EmailDomainRestrictionEnabled':
|
case 'EmailDomainRestrictionEnabled':
|
||||||
case 'EmailAliasRestrictionEnabled':
|
case 'EmailAliasRestrictionEnabled':
|
||||||
case 'SMTPSSLEnabled':
|
case 'SMTPSSLEnabled':
|
||||||
|
|
@ -1180,6 +1182,15 @@ const SystemSetting = () => {
|
||||||
>
|
>
|
||||||
{t('允许 Turnstile 用户校验')}
|
{t('允许 Turnstile 用户校验')}
|
||||||
</Form.Checkbox>
|
</Form.Checkbox>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='CaptchaEnabled'
|
||||||
|
noLabel
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('CaptchaEnabled', e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('允许图片验证码校验')}
|
||||||
|
</Form.Checkbox>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
<Form.Checkbox
|
<Form.Checkbox
|
||||||
|
|
|
||||||
|
|
@ -926,6 +926,7 @@
|
||||||
"允许 AccountFilter 参数": "Allow AccountFilter parameter",
|
"允许 AccountFilter 参数": "Allow AccountFilter parameter",
|
||||||
"允许 HTTP 协议图片请求(适用于自部署代理)": "Allow HTTP protocol image requests (for self-deployed proxies)",
|
"允许 HTTP 协议图片请求(适用于自部署代理)": "Allow HTTP protocol image requests (for self-deployed proxies)",
|
||||||
"允许 Turnstile 用户校验": "Allow Turnstile user verification",
|
"允许 Turnstile 用户校验": "Allow Turnstile user verification",
|
||||||
|
"允许图片验证码校验": "Allow image captcha verification",
|
||||||
"允许 inference_geo 透传": "Allow inference_geo Pass-through",
|
"允许 inference_geo 透传": "Allow inference_geo Pass-through",
|
||||||
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
|
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
|
||||||
"允许 service_tier 透传": "Allow service_tier Pass-through",
|
"允许 service_tier 透传": "Allow service_tier Pass-through",
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,7 @@
|
||||||
"允许 safety_identifier 透传": "允许 safety_identifier 透传",
|
"允许 safety_identifier 透传": "允许 safety_identifier 透传",
|
||||||
"允许 service_tier 透传": "允许 service_tier 透传",
|
"允许 service_tier 透传": "允许 service_tier 透传",
|
||||||
"允许 Turnstile 用户校验": "允许 Turnstile 用户校验",
|
"允许 Turnstile 用户校验": "允许 Turnstile 用户校验",
|
||||||
|
"允许图片验证码校验": "允许图片验证码校验",
|
||||||
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
||||||
"允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
|
"允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
|
||||||
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
|
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue