From 6703cc1c6617bf4d692e282b2bf6bc3fbb25dfba Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 3 Aug 2026 19:09:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/biz/team/handler/http/v1/user.go | 2 +- .../team/handler/http/v1/user_captcha_test.go | 66 +++++++++++++++++++ backend/biz/user/handler/v1/auth.go | 2 +- .../biz/user/handler/v1/auth_captcha_test.go | 66 +++++++++++++++++++ backend/config/config.go | 2 + backend/config/oss_config_test.go | 19 ++++++ backend/config/server/config.yaml.example | 2 + backend/docs/swagger.json | 5 ++ backend/domain/server_config.go | 2 + frontend/src/api/Api.ts | 5 ++ frontend/src/pages/login.tsx | 13 ++-- 11 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 backend/biz/team/handler/http/v1/user_captcha_test.go create mode 100644 backend/biz/user/handler/v1/auth_captcha_test.go diff --git a/backend/biz/team/handler/http/v1/user.go b/backend/biz/team/handler/http/v1/user.go index b060c8363..5648e9759 100644 --- a/backend/biz/team/handler/http/v1/user.go +++ b/backend/biz/team/handler/http/v1/user.go @@ -97,7 +97,7 @@ func NewTeamGroupUserHandler(i *do.Injector) (*TeamGroupUserHandler, error) { // @Router /api/v1/teams/users/login [post] func (h *TeamGroupUserHandler) Login(c *web.Context, req domain.TeamLoginReq) error { ctx := c.Request().Context() - if !h.captcha.ValidateToken(ctx, req.CaptchaToken) { + if h.config.Security.LoginCaptchaEnabled && !h.captcha.ValidateToken(ctx, req.CaptchaToken) { return errcode.ErrForbidden } diff --git a/backend/biz/team/handler/http/v1/user_captcha_test.go b/backend/biz/team/handler/http/v1/user_captcha_test.go new file mode 100644 index 000000000..4f7359ae1 --- /dev/null +++ b/backend/biz/team/handler/http/v1/user_captcha_test.go @@ -0,0 +1,66 @@ +package v1 + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http/httptest" + "testing" + + "github.com/GoYoko/web" + "github.com/labstack/echo/v4" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/pkg/captcha" +) + +func TestTeamLoginCaptchaToggle(t *testing.T) { + tests := []struct { + name string + enabled bool + wantErr error + called bool + }{ + {name: "enabled", enabled: true, wantErr: errcode.ErrForbidden}, + {name: "disabled", enabled: false, wantErr: errcode.ErrLoginFailed, called: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usecase := &teamLoginUsecaseStub{} + h := &TeamGroupUserHandler{ + config: &config.Config{Security: config.Security{LoginCaptchaEnabled: tt.enabled}}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: usecase, + captcha: captcha.NewCaptcha(), + } + + err := h.Login(teamTestWebContext(), domain.TeamLoginReq{}) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("Login() error = %v, want %v", err, tt.wantErr) + } + if usecase.called != tt.called { + t.Fatalf("Login usecase called = %v, want %v", usecase.called, tt.called) + } + }) + } +} + +func teamTestWebContext() *web.Context { + e := echo.New() + req := httptest.NewRequest("POST", "/", nil) + return &web.Context{Context: e.NewContext(req, httptest.NewRecorder())} +} + +type teamLoginUsecaseStub struct { + domain.TeamGroupUserUsecase + called bool +} + +func (s *teamLoginUsecaseStub) Login(context.Context, *domain.TeamLoginReq) (*domain.User, error) { + s.called = true + return nil, errors.New("login failed") +} diff --git a/backend/biz/user/handler/v1/auth.go b/backend/biz/user/handler/v1/auth.go index abd6590be..bcb7c0aae 100644 --- a/backend/biz/user/handler/v1/auth.go +++ b/backend/biz/user/handler/v1/auth.go @@ -242,7 +242,7 @@ func oauthErrorCode(err error) string { // @Router /api/v1/users/password-login [post] func (h *AuthHandler) PasswordLogin(c *web.Context, req domain.TeamLoginReq) error { ctx := c.Request().Context() - if !h.captcha.ValidateToken(ctx, req.CaptchaToken) { + if h.config.Security.LoginCaptchaEnabled && !h.captcha.ValidateToken(ctx, req.CaptchaToken) { return errcode.ErrForbidden } diff --git a/backend/biz/user/handler/v1/auth_captcha_test.go b/backend/biz/user/handler/v1/auth_captcha_test.go new file mode 100644 index 000000000..22cd29b6c --- /dev/null +++ b/backend/biz/user/handler/v1/auth_captcha_test.go @@ -0,0 +1,66 @@ +package v1 + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http/httptest" + "testing" + + "github.com/GoYoko/web" + "github.com/labstack/echo/v4" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/pkg/captcha" +) + +func TestPasswordLoginCaptchaToggle(t *testing.T) { + tests := []struct { + name string + enabled bool + wantErr error + called bool + }{ + {name: "enabled", enabled: true, wantErr: errcode.ErrForbidden}, + {name: "disabled", enabled: false, wantErr: errcode.ErrLoginFailed, called: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usecase := &passwordLoginUsecaseStub{} + h := &AuthHandler{ + config: &config.Config{Security: config.Security{LoginCaptchaEnabled: tt.enabled}}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: usecase, + captcha: captcha.NewCaptcha(), + } + + err := h.PasswordLogin(testWebContext(), domain.TeamLoginReq{}) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("PasswordLogin() error = %v, want %v", err, tt.wantErr) + } + if usecase.called != tt.called { + t.Fatalf("PasswordLogin usecase called = %v, want %v", usecase.called, tt.called) + } + }) + } +} + +func testWebContext() *web.Context { + e := echo.New() + req := httptest.NewRequest("POST", "/", nil) + return &web.Context{Context: e.NewContext(req, httptest.NewRecorder())} +} + +type passwordLoginUsecaseStub struct { + domain.UserUsecase + called bool +} + +func (s *passwordLoginUsecaseStub) PasswordLogin(context.Context, *domain.TeamLoginReq) (*domain.User, error) { + s.called = true + return nil, errors.New("login failed") +} diff --git a/backend/config/config.go b/backend/config/config.go index bf2f48fe4..e95213784 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -98,6 +98,7 @@ type Config struct { type Security struct { BlockPrivateNetwork bool `mapstructure:"block_private_network"` + LoginCaptchaEnabled bool `mapstructure:"login_captcha_enabled"` } type ReviewAgent struct { @@ -332,6 +333,7 @@ func Init(dir string) (*Config, error) { v.SetDefault("server.addr", ":8888") v.SetDefault("server.base_url", "") v.SetDefault("security.block_private_network", false) + v.SetDefault("security.login_captcha_enabled", true) v.SetDefault("loki.addr", "http://monkeycode-ai-loki:3100") v.SetDefault("clickhouse.addr", "") v.SetDefault("clickhouse.database", "") diff --git a/backend/config/oss_config_test.go b/backend/config/oss_config_test.go index 7c4d797f3..03db653be 100644 --- a/backend/config/oss_config_test.go +++ b/backend/config/oss_config_test.go @@ -102,3 +102,22 @@ func TestPrivateNetworkBlockCanBeConfiguredByEnv(t *testing.T) { t.Fatal("security.block_private_network = false, want true") } } + +func TestLoginCaptchaCanBeConfiguredByEnv(t *testing.T) { + cfg, err := Init(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if !cfg.Security.LoginCaptchaEnabled { + t.Fatal("security.login_captcha_enabled = false, want true") + } + + t.Setenv("MCAI_SECURITY_LOGIN_CAPTCHA_ENABLED", "false") + cfg, err = Init(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg.Security.LoginCaptchaEnabled { + t.Fatal("security.login_captcha_enabled = true, want false") + } +} diff --git a/backend/config/server/config.yaml.example b/backend/config/server/config.yaml.example index bd98c8274..45d624448 100644 --- a/backend/config/server/config.yaml.example +++ b/backend/config/server/config.yaml.example @@ -8,6 +8,8 @@ security: # SaaS 环境设为 true;私有化默认关闭,以允许访问部署方内网服务。 # 可通过 MCAI_SECURITY_BLOCK_PRIVATE_NETWORK 环境变量覆盖。 block_private_network: false + # 可通过 MCAI_SECURITY_LOGIN_CAPTCHA_ENABLED 环境变量覆盖。 + login_captcha_enabled: true database: master: "postgres://monkeycode:monkeycode@localhost:5432/monkeycode?sslmode=disable" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 37ce9cff5..ab743c10e 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -12441,6 +12441,11 @@ "type": "string", "example": "v1.2.4" }, + "login_captcha_enabled": { + "description": "LoginCaptchaEnabled 登录时是否启用 captcha 验证。", + "type": "boolean", + "example": true + }, "region": { "description": "Region SaaS 区域,国内 SaaS 返回 cn,海外 SaaS 返回 global。", "enum": [ diff --git a/backend/domain/server_config.go b/backend/domain/server_config.go index 9bcd9d9de..381c21d5e 100644 --- a/backend/domain/server_config.go +++ b/backend/domain/server_config.go @@ -31,6 +31,8 @@ type ServerConfig struct { CurrentVersion string `json:"current_version,omitempty" example:"v1.2.3"` // LatestVersion 最新可用版本。 LatestVersion string `json:"latest_version,omitempty" example:"v1.2.4"` + // LoginCaptchaEnabled 登录时是否启用 captcha 验证。 + LoginCaptchaEnabled bool `json:"login_captcha_enabled" example:"true"` } type ServerConfigProvider interface { diff --git a/frontend/src/api/Api.ts b/frontend/src/api/Api.ts index 02aef416f..186f86d29 100644 --- a/frontend/src/api/Api.ts +++ b/frontend/src/api/Api.ts @@ -2009,6 +2009,11 @@ export interface GithubComChaitinMonkeyCodeBackendDomainServerConfig { * @example "v1.2.4" */ latest_version?: string; + /** + * LoginCaptchaEnabled 登录时是否启用 captcha 验证。 + * @example true + */ + login_captcha_enabled?: boolean; /** * Region SaaS 区域,国内 SaaS 返回 cn,海外 SaaS 返回 global。 * @example "cn" diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index cc0cc1bae..af354be5a 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -54,6 +54,7 @@ export default function LoginPage({ const { t } = useTranslation() const { reloadAuth, serverConfig } = useAppRuntime() const serverRegion = serverConfig?.region as string | undefined + const loginCaptchaEnabled = serverConfig?.login_captcha_enabled !== false const isCnRegion = serverRegion === "cn" const isGlobalRegion = serverRegion === "global" const inviterId = typeof window !== 'undefined' ? (localStorage.getItem('ic') || '') : '' @@ -116,12 +117,12 @@ export default function LoginPage({ setLogging(true) - const token = await captchaChallenge(); - if (token) { + const token = loginCaptchaEnabled ? await captchaChallenge() : ''; + if (!loginCaptchaEnabled || token) { await apiRequest('v1UsersPasswordLoginCreate', { email: userEmail.trim(), password: userPassword.trim(), - captcha_token: token, + captcha_token: token || '', }, [], async (resp) => { if (resp.code === 0) { localStorage.setItem(USER_STORAGE_KEY, JSON.stringify({ email: userEmail.trim(), password: userPassword.trim() })) @@ -172,13 +173,13 @@ export default function LoginPage({ setLogging(true) - const token = await captchaChallenge(); - if (token) { + const token = loginCaptchaEnabled ? await captchaChallenge() : ''; + if (!loginCaptchaEnabled || token) { await apiRequest('v1TeamsUsersLoginCreate', { email: teamManagerEmail.trim(), password: teamManagerPassword.trim(), - captcha_token: token, + captcha_token: token || '', }, [], (resp) => { if (resp.code === 0) { localStorage.setItem(MANAGER_STORAGE_KEY, JSON.stringify({ email: teamManagerEmail.trim(), password: teamManagerPassword.trim() })) From cb57a542d251b0df5342dbd31779fe23c7586692 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 3 Aug 2026 19:40:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E5=BC=80=E5=85=B3=E4=B8=BA=E5=85=A8=E5=B1=80=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/biz/team/handler/http/v1/user.go | 2 +- .../team/handler/http/v1/user_captcha_test.go | 4 +- backend/biz/user/handler/v1/auth.go | 2 +- .../biz/user/handler/v1/auth_captcha_test.go | 45 +++++++++++++++++-- backend/config/config.go | 4 +- backend/config/oss_config_test.go | 12 ++--- backend/config/server/config.yaml.example | 4 +- backend/docs/swagger.json | 10 ++--- backend/domain/server_config.go | 4 +- backend/pkg/captcha/captcha.go | 16 ++++++- backend/pkg/captcha/captcha_test.go | 23 ++++++++++ backend/pkg/register.go | 3 +- frontend/src/api/Api.ts | 10 ++--- .../src/components/app-runtime-provider.tsx | 2 + .../components/console/nav/nav-checkin.tsx | 6 ++- .../components/console/nav/wallet-dialog.tsx | 6 +-- .../components/manager/team-members-card.tsx | 6 ++- .../src/pages/console/manager/manager.tsx | 6 ++- frontend/src/pages/findpassword.tsx | 6 ++- frontend/src/pages/login.tsx | 11 +++-- frontend/src/utils/common.tsx | 5 ++- 21 files changed, 136 insertions(+), 51 deletions(-) create mode 100644 backend/pkg/captcha/captcha_test.go diff --git a/backend/biz/team/handler/http/v1/user.go b/backend/biz/team/handler/http/v1/user.go index 5648e9759..b060c8363 100644 --- a/backend/biz/team/handler/http/v1/user.go +++ b/backend/biz/team/handler/http/v1/user.go @@ -97,7 +97,7 @@ func NewTeamGroupUserHandler(i *do.Injector) (*TeamGroupUserHandler, error) { // @Router /api/v1/teams/users/login [post] func (h *TeamGroupUserHandler) Login(c *web.Context, req domain.TeamLoginReq) error { ctx := c.Request().Context() - if h.config.Security.LoginCaptchaEnabled && !h.captcha.ValidateToken(ctx, req.CaptchaToken) { + if !h.captcha.ValidateToken(ctx, req.CaptchaToken) { return errcode.ErrForbidden } diff --git a/backend/biz/team/handler/http/v1/user_captcha_test.go b/backend/biz/team/handler/http/v1/user_captcha_test.go index 4f7359ae1..cfdc9fb85 100644 --- a/backend/biz/team/handler/http/v1/user_captcha_test.go +++ b/backend/biz/team/handler/http/v1/user_captcha_test.go @@ -11,7 +11,6 @@ import ( "github.com/GoYoko/web" "github.com/labstack/echo/v4" - "github.com/chaitin/MonkeyCode/backend/config" "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/errcode" "github.com/chaitin/MonkeyCode/backend/pkg/captcha" @@ -32,10 +31,9 @@ func TestTeamLoginCaptchaToggle(t *testing.T) { t.Run(tt.name, func(t *testing.T) { usecase := &teamLoginUsecaseStub{} h := &TeamGroupUserHandler{ - config: &config.Config{Security: config.Security{LoginCaptchaEnabled: tt.enabled}}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), usecase: usecase, - captcha: captcha.NewCaptcha(), + captcha: captcha.NewCaptcha(tt.enabled), } err := h.Login(teamTestWebContext(), domain.TeamLoginReq{}) diff --git a/backend/biz/user/handler/v1/auth.go b/backend/biz/user/handler/v1/auth.go index bcb7c0aae..abd6590be 100644 --- a/backend/biz/user/handler/v1/auth.go +++ b/backend/biz/user/handler/v1/auth.go @@ -242,7 +242,7 @@ func oauthErrorCode(err error) string { // @Router /api/v1/users/password-login [post] func (h *AuthHandler) PasswordLogin(c *web.Context, req domain.TeamLoginReq) error { ctx := c.Request().Context() - if h.config.Security.LoginCaptchaEnabled && !h.captcha.ValidateToken(ctx, req.CaptchaToken) { + if !h.captcha.ValidateToken(ctx, req.CaptchaToken) { return errcode.ErrForbidden } diff --git a/backend/biz/user/handler/v1/auth_captcha_test.go b/backend/biz/user/handler/v1/auth_captcha_test.go index 22cd29b6c..4ca301cff 100644 --- a/backend/biz/user/handler/v1/auth_captcha_test.go +++ b/backend/biz/user/handler/v1/auth_captcha_test.go @@ -11,7 +11,6 @@ import ( "github.com/GoYoko/web" "github.com/labstack/echo/v4" - "github.com/chaitin/MonkeyCode/backend/config" "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/errcode" "github.com/chaitin/MonkeyCode/backend/pkg/captcha" @@ -32,10 +31,9 @@ func TestPasswordLoginCaptchaToggle(t *testing.T) { t.Run(tt.name, func(t *testing.T) { usecase := &passwordLoginUsecaseStub{} h := &AuthHandler{ - config: &config.Config{Security: config.Security{LoginCaptchaEnabled: tt.enabled}}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), usecase: usecase, - captcha: captcha.NewCaptcha(), + captcha: captcha.NewCaptcha(tt.enabled), } err := h.PasswordLogin(testWebContext(), domain.TeamLoginReq{}) @@ -49,6 +47,37 @@ func TestPasswordLoginCaptchaToggle(t *testing.T) { } } +func TestResetPasswordCaptchaToggle(t *testing.T) { + tests := []struct { + name string + enabled bool + wantErr error + called bool + }{ + {name: "enabled", enabled: true, wantErr: errcode.ErrForbidden}, + {name: "disabled", enabled: false, wantErr: errCaptchaUsecase, called: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usecase := &passwordLoginUsecaseStub{} + h := &AuthHandler{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: usecase, + captcha: captcha.NewCaptcha(tt.enabled), + } + + err := h.SendResetPasswordEmail(testWebContext(), domain.ResetUserPasswordEmailReq{Emails: []string{"user@example.com"}}) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("SendResetPasswordEmail() error = %v, want %v", err, tt.wantErr) + } + if usecase.resetCalled != tt.called { + t.Fatalf("SendResetPasswordEmail usecase called = %v, want %v", usecase.resetCalled, tt.called) + } + }) + } +} + func testWebContext() *web.Context { e := echo.New() req := httptest.NewRequest("POST", "/", nil) @@ -57,10 +86,18 @@ func testWebContext() *web.Context { type passwordLoginUsecaseStub struct { domain.UserUsecase - called bool + called bool + resetCalled bool } func (s *passwordLoginUsecaseStub) PasswordLogin(context.Context, *domain.TeamLoginReq) (*domain.User, error) { s.called = true return nil, errors.New("login failed") } + +var errCaptchaUsecase = errors.New("usecase called") + +func (s *passwordLoginUsecaseStub) SendResetPasswordEmail(context.Context, *domain.ResetUserPasswordEmailReq) error { + s.resetCalled = true + return errCaptchaUsecase +} diff --git a/backend/config/config.go b/backend/config/config.go index e95213784..f0394657d 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -98,7 +98,7 @@ type Config struct { type Security struct { BlockPrivateNetwork bool `mapstructure:"block_private_network"` - LoginCaptchaEnabled bool `mapstructure:"login_captcha_enabled"` + CaptchaEnabled bool `mapstructure:"captcha_enabled"` } type ReviewAgent struct { @@ -333,7 +333,7 @@ func Init(dir string) (*Config, error) { v.SetDefault("server.addr", ":8888") v.SetDefault("server.base_url", "") v.SetDefault("security.block_private_network", false) - v.SetDefault("security.login_captcha_enabled", true) + v.SetDefault("security.captcha_enabled", true) v.SetDefault("loki.addr", "http://monkeycode-ai-loki:3100") v.SetDefault("clickhouse.addr", "") v.SetDefault("clickhouse.database", "") diff --git a/backend/config/oss_config_test.go b/backend/config/oss_config_test.go index 03db653be..4cc58e120 100644 --- a/backend/config/oss_config_test.go +++ b/backend/config/oss_config_test.go @@ -103,21 +103,21 @@ func TestPrivateNetworkBlockCanBeConfiguredByEnv(t *testing.T) { } } -func TestLoginCaptchaCanBeConfiguredByEnv(t *testing.T) { +func TestCaptchaCanBeConfiguredByEnv(t *testing.T) { cfg, err := Init(t.TempDir()) if err != nil { t.Fatal(err) } - if !cfg.Security.LoginCaptchaEnabled { - t.Fatal("security.login_captcha_enabled = false, want true") + if !cfg.Security.CaptchaEnabled { + t.Fatal("security.captcha_enabled = false, want true") } - t.Setenv("MCAI_SECURITY_LOGIN_CAPTCHA_ENABLED", "false") + t.Setenv("MCAI_SECURITY_CAPTCHA_ENABLED", "false") cfg, err = Init(t.TempDir()) if err != nil { t.Fatal(err) } - if cfg.Security.LoginCaptchaEnabled { - t.Fatal("security.login_captcha_enabled = true, want false") + if cfg.Security.CaptchaEnabled { + t.Fatal("security.captcha_enabled = true, want false") } } diff --git a/backend/config/server/config.yaml.example b/backend/config/server/config.yaml.example index 45d624448..dd738057c 100644 --- a/backend/config/server/config.yaml.example +++ b/backend/config/server/config.yaml.example @@ -8,8 +8,8 @@ security: # SaaS 环境设为 true;私有化默认关闭,以允许访问部署方内网服务。 # 可通过 MCAI_SECURITY_BLOCK_PRIVATE_NETWORK 环境变量覆盖。 block_private_network: false - # 可通过 MCAI_SECURITY_LOGIN_CAPTCHA_ENABLED 环境变量覆盖。 - login_captcha_enabled: true + # 可通过 MCAI_SECURITY_CAPTCHA_ENABLED 环境变量覆盖。 + captcha_enabled: true database: master: "postgres://monkeycode:monkeycode@localhost:5432/monkeycode?sslmode=disable" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index ab743c10e..15c9b9467 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -12418,6 +12418,11 @@ "domain.ServerConfig": { "type": "object", "properties": { + "captcha_enabled": { + "description": "CaptchaEnabled 是否启用 captcha 验证。", + "type": "boolean", + "example": true + }, "current_version": { "description": "CurrentVersion 当前服务版本。", "type": "string", @@ -12441,11 +12446,6 @@ "type": "string", "example": "v1.2.4" }, - "login_captcha_enabled": { - "description": "LoginCaptchaEnabled 登录时是否启用 captcha 验证。", - "type": "boolean", - "example": true - }, "region": { "description": "Region SaaS 区域,国内 SaaS 返回 cn,海外 SaaS 返回 global。", "enum": [ diff --git a/backend/domain/server_config.go b/backend/domain/server_config.go index 381c21d5e..37bf554f1 100644 --- a/backend/domain/server_config.go +++ b/backend/domain/server_config.go @@ -31,8 +31,8 @@ type ServerConfig struct { CurrentVersion string `json:"current_version,omitempty" example:"v1.2.3"` // LatestVersion 最新可用版本。 LatestVersion string `json:"latest_version,omitempty" example:"v1.2.4"` - // LoginCaptchaEnabled 登录时是否启用 captcha 验证。 - LoginCaptchaEnabled bool `json:"login_captcha_enabled" example:"true"` + // CaptchaEnabled 是否启用 captcha 验证。 + CaptchaEnabled bool `json:"captcha_enabled" example:"true"` } type ServerConfigProvider interface { diff --git a/backend/pkg/captcha/captcha.go b/backend/pkg/captcha/captcha.go index 31d67d4a0..b693aa412 100644 --- a/backend/pkg/captcha/captcha.go +++ b/backend/pkg/captcha/captcha.go @@ -8,18 +8,32 @@ import ( type Captcha struct { *gocap.Cap + enabled bool } -func NewCaptcha() *Captcha { +func NewCaptcha(enabled ...bool) *Captcha { + captchaEnabled := true + if len(enabled) > 0 { + captchaEnabled = enabled[0] + } return &Captcha{ Cap: gocap.New( gocap.WithChallenge(50, 32, 3), gocap.WithChallengeExpires(60*2), gocap.WithTokenExpires(60*5), ), + enabled: captchaEnabled, } } +func (c *Captcha) SetEnabled(enabled bool) { + c.enabled = enabled +} + +func (c *Captcha) ValidateToken(ctx context.Context, token string) bool { + return !c.enabled || c.Cap.ValidateToken(ctx, token) +} + // Verify 验证验证码 token func (c *Captcha) Verify(token string, solutions []int64) (bool, error) { _, err := c.Cap.RedeemChallenge(context.Background(), token, solutions) diff --git a/backend/pkg/captcha/captcha_test.go b/backend/pkg/captcha/captcha_test.go new file mode 100644 index 000000000..a48f623cd --- /dev/null +++ b/backend/pkg/captcha/captcha_test.go @@ -0,0 +1,23 @@ +package captcha + +import ( + "context" + "testing" +) + +func TestValidateTokenRespectsEnabled(t *testing.T) { + ctx := context.Background() + + if NewCaptcha().ValidateToken(ctx, "") { + t.Fatal("enabled captcha accepted an empty token") + } + if !NewCaptcha(false).ValidateToken(ctx, "") { + t.Fatal("disabled captcha rejected an empty token") + } + + cap := NewCaptcha() + cap.SetEnabled(false) + if !cap.ValidateToken(ctx, "") { + t.Fatal("SetEnabled(false) did not disable validation") + } +} diff --git a/backend/pkg/register.go b/backend/pkg/register.go index 59dcbec52..b66360349 100644 --- a/backend/pkg/register.go +++ b/backend/pkg/register.go @@ -74,7 +74,8 @@ func RegisterInfra(i *do.Injector, w ...*web.Web) error { // Captcha do.Provide(i, func(i *do.Injector) (*captcha.Captcha, error) { - return captcha.NewCaptcha(), nil + cfg := do.MustInvoke[*config.Config](i) + return captcha.NewCaptcha(cfg.Security.CaptchaEnabled), nil }) do.Provide(i, email.NewSMTPClient) diff --git a/frontend/src/api/Api.ts b/frontend/src/api/Api.ts index 186f86d29..7b1d03835 100644 --- a/frontend/src/api/Api.ts +++ b/frontend/src/api/Api.ts @@ -384,7 +384,7 @@ export interface DomainCheckByConfigReq { } export interface DomainCheckInReq { - captcha_token: string; + captcha_token?: string; } export interface DomainCheckInResp { @@ -1227,7 +1227,7 @@ export interface DomainRepositoryItem { export interface DomainResetUserPasswordEmailReq { /** 验证码Token */ - captcha_token: string; + captcha_token?: string; /** 发送重置密码邮件的邮箱列表 */ emails: string[]; } @@ -1466,7 +1466,7 @@ export interface DomainTeamImage { export interface DomainTeamLoginReq { /** 验证码Token */ - captcha_token: string; + captcha_token?: string; /** 用户邮箱 */ email: string; /** 用户密码(MD5加密后的值) */ @@ -2010,10 +2010,10 @@ export interface GithubComChaitinMonkeyCodeBackendDomainServerConfig { */ latest_version?: string; /** - * LoginCaptchaEnabled 登录时是否启用 captcha 验证。 + * CaptchaEnabled 是否启用 captcha 验证。 * @example true */ - login_captcha_enabled?: boolean; + captcha_enabled?: boolean; /** * Region SaaS 区域,国内 SaaS 返回 cn,海外 SaaS 返回 global。 * @example "cn" diff --git a/frontend/src/components/app-runtime-provider.tsx b/frontend/src/components/app-runtime-provider.tsx index d8a9d4f09..4c1cebef6 100644 --- a/frontend/src/components/app-runtime-provider.tsx +++ b/frontend/src/components/app-runtime-provider.tsx @@ -27,6 +27,7 @@ export type RuntimeAuthState = { type AppRuntimeContextValue = { serverConfig: ServerConfig | null; serverConfigLoading: boolean; + captchaEnabled: boolean; auth: RuntimeAuthState; reloadServerConfig: () => Promise; reloadAuth: () => Promise; @@ -121,6 +122,7 @@ export function AppRuntimeProvider({ children }: { children: ReactNode }) { () => ({ serverConfig, serverConfigLoading, + captchaEnabled: serverConfig?.captcha_enabled !== false, auth, reloadServerConfig, reloadAuth, diff --git a/frontend/src/components/console/nav/nav-checkin.tsx b/frontend/src/components/console/nav/nav-checkin.tsx index f41745777..6c3f661e2 100644 --- a/frontend/src/components/console/nav/nav-checkin.tsx +++ b/frontend/src/components/console/nav/nav-checkin.tsx @@ -7,9 +7,11 @@ import { apiRequest } from "@/utils/requestUtils" import { Gift } from "lucide-react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { useAppRuntime } from "@/components/app-runtime-provider" export default function NavCheckin() { const { t } = useTranslation() + const { captchaEnabled } = useAppRuntime() const { checkedInToday, reloadCheckinStatus, reloadWallet } = useCommonData() const [submitting, setSubmitting] = React.useState(false) @@ -20,8 +22,8 @@ export default function NavCheckin() { setSubmitting(true) - const captchaToken = await captchaChallenge() - if (!captchaToken) { + const captchaToken = await captchaChallenge(captchaEnabled) + if (captchaToken === null) { toast.error(t("consoleShell.rewards.toast.captchaFailed")) setSubmitting(false) return diff --git a/frontend/src/components/console/nav/wallet-dialog.tsx b/frontend/src/components/console/nav/wallet-dialog.tsx index f64be8616..befd6defd 100644 --- a/frontend/src/components/console/nav/wallet-dialog.tsx +++ b/frontend/src/components/console/nav/wallet-dialog.tsx @@ -38,7 +38,7 @@ type WalletSectionId = (typeof WALLET_NAV)[number]["id"] export default function WalletDialog() { const { t } = useTranslation() - const { serverConfig } = useAppRuntime() + const { captchaEnabled, serverConfig } = useAppRuntime() const pricingRegion = getPricingRegion(serverConfig?.region) const isGlobalRegion = serverConfig?.region === "global" const [open, setOpen] = useState(false) @@ -353,8 +353,8 @@ export default function WalletDialog() { setIsCheckinSubmitting(true) - const captchaToken = await captchaChallenge() - if (!captchaToken) { + const captchaToken = await captchaChallenge(captchaEnabled) + if (captchaToken === null) { toast.error(t("walletDialog.toast.captchaFailed")) setIsCheckinSubmitting(false) return diff --git a/frontend/src/components/manager/team-members-card.tsx b/frontend/src/components/manager/team-members-card.tsx index 46701973a..a5440d35e 100644 --- a/frontend/src/components/manager/team-members-card.tsx +++ b/frontend/src/components/manager/team-members-card.tsx @@ -16,6 +16,7 @@ import { captchaChallenge } from "@/utils/common"; import { toast } from "sonner"; import dayjs from "dayjs"; import { useTranslation } from "react-i18next"; +import { useAppRuntime } from "@/components/app-runtime-provider"; interface TeamMembersCardProps { members: any[]; @@ -34,6 +35,7 @@ const ADD_MEMBER_ERROR_MATCHERS = { export default function TeamMembersCard({ members, memberLimit, usedSeats, groups, onRefreshMembers, onRefreshGroups }: TeamMembersCardProps) { const { t } = useTranslation(); + const { captchaEnabled } = useAppRuntime(); const isOfflineEdition = import.meta.env.VITE_APP_EDITION === "offline"; const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); const [emails, setEmails] = useState(""); @@ -191,8 +193,8 @@ export default function TeamMembersCard({ members, memberLimit, usedSeats, group } }) } else { - const captchaToken = await captchaChallenge(); - if (!captchaToken) { + const captchaToken = await captchaChallenge(captchaEnabled); + if (captchaToken === null) { toast.error(t("managerMembers.toast.captchaFailed")); setResettingPassword(false); return; diff --git a/frontend/src/pages/console/manager/manager.tsx b/frontend/src/pages/console/manager/manager.tsx index 2bd7b4d8f..d163d6b21 100644 --- a/frontend/src/pages/console/manager/manager.tsx +++ b/frontend/src/pages/console/manager/manager.tsx @@ -36,9 +36,11 @@ import { import { captchaChallenge } from "@/utils/common"; import { apiRequest } from "@/utils/requestUtils"; import { useTranslation } from "react-i18next"; +import { useAppRuntime } from "@/components/app-runtime-provider"; export default function TeamManagerManager() { const { t } = useTranslation(); + const { captchaEnabled } = useAppRuntime(); const isOfflineEdition = import.meta.env.VITE_APP_EDITION === "offline"; const [managers, setManagers] = useState([]); const [addDialogOpen, setAddDialogOpen] = useState(false); @@ -195,8 +197,8 @@ export default function TeamManagerManager() { } }); } else { - const captchaToken = await captchaChallenge(); - if (!captchaToken) { + const captchaToken = await captchaChallenge(captchaEnabled); + if (captchaToken === null) { toast.error(t("managerAdmins.toast.captchaFailed")); setResettingPassword(false); return; diff --git a/frontend/src/pages/findpassword.tsx b/frontend/src/pages/findpassword.tsx index 1d9ef2412..5af717a02 100644 --- a/frontend/src/pages/findpassword.tsx +++ b/frontend/src/pages/findpassword.tsx @@ -5,6 +5,7 @@ import { Field } from "@/components/ui/field"; import { FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { useAppRuntime } from "@/components/app-runtime-provider"; import { captchaChallenge, isValidEmail } from "@/utils/common"; import { apiRequest } from "@/utils/requestUtils"; import { useState } from "react"; @@ -15,6 +16,7 @@ import { toast } from "sonner"; export default function FindPasswordPage() { const { t } = useTranslation(); + const { captchaEnabled } = useAppRuntime(); const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); @@ -26,8 +28,8 @@ export default function FindPasswordPage() { setLoading(true); - const token = await captchaChallenge(); - if (token) { + const token = await captchaChallenge(captchaEnabled); + if (token !== null) { await apiRequest('v1UsersPasswordsResetRequestUpdate', { emails: [email], captcha_token: token diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index af354be5a..37af262d6 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -52,9 +52,8 @@ export default function LoginPage({ const [defaultOIDCConfig, setDefaultOIDCConfig] = React.useState(null) const navigate = useNavigate() const { t } = useTranslation() - const { reloadAuth, serverConfig } = useAppRuntime() + const { captchaEnabled, reloadAuth, serverConfig } = useAppRuntime() const serverRegion = serverConfig?.region as string | undefined - const loginCaptchaEnabled = serverConfig?.login_captcha_enabled !== false const isCnRegion = serverRegion === "cn" const isGlobalRegion = serverRegion === "global" const inviterId = typeof window !== 'undefined' ? (localStorage.getItem('ic') || '') : '' @@ -117,8 +116,8 @@ export default function LoginPage({ setLogging(true) - const token = loginCaptchaEnabled ? await captchaChallenge() : ''; - if (!loginCaptchaEnabled || token) { + const token = await captchaChallenge(captchaEnabled); + if (token !== null) { await apiRequest('v1UsersPasswordLoginCreate', { email: userEmail.trim(), password: userPassword.trim(), @@ -173,8 +172,8 @@ export default function LoginPage({ setLogging(true) - const token = loginCaptchaEnabled ? await captchaChallenge() : ''; - if (!loginCaptchaEnabled || token) { + const token = await captchaChallenge(captchaEnabled); + if (token !== null) { await apiRequest('v1TeamsUsersLoginCreate', { email: teamManagerEmail.trim(), diff --git a/frontend/src/utils/common.tsx b/frontend/src/utils/common.tsx index 6c31663fe..5e003b7c4 100644 --- a/frontend/src/utils/common.tsx +++ b/frontend/src/utils/common.tsx @@ -485,7 +485,10 @@ export function isValidEmail(email: string): boolean { } -export async function captchaChallenge(): Promise { +export async function captchaChallenge(enabled = true): Promise { + if (!enabled) { + return '' + } try { const cap = new Cap({ apiEndpoint: '/api/v1/public/captcha/'