Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions backend/biz/team/handler/http/v1/user_captcha_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
103 changes: 103 additions & 0 deletions backend/biz/user/handler/v1/auth_captcha_test.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type Config struct {

type Security struct {
BlockPrivateNetwork bool `mapstructure:"block_private_network"`
CaptchaEnabled bool `mapstructure:"captcha_enabled"`
}

type ReviewAgent struct {
Expand Down Expand Up @@ -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", "")
Expand Down
19 changes: 19 additions & 0 deletions backend/config/oss_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
2 changes: 2 additions & 0 deletions backend/config/server/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -12418,6 +12418,11 @@
"domain.ServerConfig": {
"type": "object",
"properties": {
"captcha_enabled": {
"description": "CaptchaEnabled 是否启用 captcha 验证。",
"type": "boolean",
"example": true
},
"current_version": {
"description": "CurrentVersion 当前服务版本。",
"type": "string",
Expand Down
2 changes: 2 additions & 0 deletions backend/domain/server_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion backend/pkg/captcha/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions backend/pkg/captcha/captcha_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 2 additions & 1 deletion backend/pkg/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/api/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export interface DomainCheckByConfigReq {
}

export interface DomainCheckInReq {
captcha_token: string;
captcha_token?: string;
}

export interface DomainCheckInResp {
Expand Down Expand Up @@ -1227,7 +1227,7 @@ export interface DomainRepositoryItem {

export interface DomainResetUserPasswordEmailReq {
/** 验证码Token */
captcha_token: string;
captcha_token?: string;
/** 发送重置密码邮件的邮箱列表 */
emails: string[];
}
Expand Down Expand Up @@ -1466,7 +1466,7 @@ export interface DomainTeamImage {

export interface DomainTeamLoginReq {
/** 验证码Token */
captcha_token: string;
captcha_token?: string;
/** 用户邮箱 */
email: string;
/** 用户密码(MD5加密后的值) */
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/app-runtime-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type RuntimeAuthState = {
type AppRuntimeContextValue = {
serverConfig: ServerConfig | null;
serverConfigLoading: boolean;
captchaEnabled: boolean;
auth: RuntimeAuthState;
reloadServerConfig: () => Promise<ServerConfig | null>;
reloadAuth: () => Promise<RuntimeAuthState>;
Expand Down Expand Up @@ -121,6 +122,7 @@ export function AppRuntimeProvider({ children }: { children: ReactNode }) {
() => ({
serverConfig,
serverConfigLoading,
captchaEnabled: serverConfig?.captcha_enabled !== false,
auth,
reloadServerConfig,
reloadAuth,
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/console/nav/nav-checkin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
Loading
Loading