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..cfdc9fb85 --- /dev/null +++ b/backend/biz/team/handler/http/v1/user_captcha_test.go @@ -0,0 +1,64 @@ +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/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{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: usecase, + captcha: captcha.NewCaptcha(tt.enabled), + } + + 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_captcha_test.go b/backend/biz/user/handler/v1/auth_captcha_test.go new file mode 100644 index 000000000..4ca301cff --- /dev/null +++ b/backend/biz/user/handler/v1/auth_captcha_test.go @@ -0,0 +1,103 @@ +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/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{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + usecase: usecase, + captcha: captcha.NewCaptcha(tt.enabled), + } + + 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 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) + return &web.Context{Context: e.NewContext(req, httptest.NewRecorder())} +} + +type passwordLoginUsecaseStub struct { + domain.UserUsecase + 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 bf2f48fe4..f0394657d 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"` + CaptchaEnabled bool `mapstructure:"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.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..4cc58e120 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 TestCaptchaCanBeConfiguredByEnv(t *testing.T) { + cfg, err := Init(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if !cfg.Security.CaptchaEnabled { + t.Fatal("security.captcha_enabled = false, want true") + } + + t.Setenv("MCAI_SECURITY_CAPTCHA_ENABLED", "false") + cfg, err = Init(t.TempDir()) + if err != nil { + t.Fatal(err) + } + 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 bd98c8274..dd738057c 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_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 37ce9cff5..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", diff --git a/backend/domain/server_config.go b/backend/domain/server_config.go index 9bcd9d9de..37bf554f1 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"` + // 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 02aef416f..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加密后的值) */ @@ -2009,6 +2009,11 @@ export interface GithubComChaitinMonkeyCodeBackendDomainServerConfig { * @example "v1.2.4" */ latest_version?: string; + /** + * CaptchaEnabled 是否启用 captcha 验证。 + * @example true + */ + 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 cc0cc1bae..37af262d6 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -52,7 +52,7 @@ 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 isCnRegion = serverRegion === "cn" const isGlobalRegion = serverRegion === "global" @@ -116,12 +116,12 @@ export default function LoginPage({ setLogging(true) - const token = await captchaChallenge(); - if (token) { + const token = await captchaChallenge(captchaEnabled); + if (token !== null) { 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 +172,13 @@ export default function LoginPage({ setLogging(true) - const token = await captchaChallenge(); - if (token) { + const token = await captchaChallenge(captchaEnabled); + if (token !== null) { 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() })) 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/'