diff --git a/.gitignore b/.gitignore index 3f4ba17..40a5587 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Binaries for programs and plugins +cloudreve *.exe *.exe~ *.dll diff --git a/assets b/assets index 43c9ce1..f544486 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 43c9ce1d266050637a247113db54883ce2218291 +Subproject commit f544486b6ae2440df197630601b1827ed6977c0b diff --git a/models/migration.go b/models/migration.go index 6d0ba49..1cd25b2 100644 --- a/models/migration.go +++ b/models/migration.go @@ -156,6 +156,9 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {Name: "captcha_IsShowSlimeLine", Value: "1", Type: "captcha"}, {Name: "captcha_IsShowSineLine", Value: "0", Type: "captcha"}, {Name: "captcha_CaptchaLen", Value: "6", Type: "captcha"}, + {Name: "captcha_IsUseReCaptcha", Value: "0", Type: "captcha"}, + {Name: "captcha_ReCaptchaKey", Value: "defaultKey", Type: "captcha"}, + {Name: "captcha_ReCaptchaSecret", Value: "defaultSecret", Type: "captcha"}, {Name: "thumb_width", Value: "400", Type: "thumb"}, {Name: "thumb_height", Value: "300", Type: "thumb"}, {Name: "pwa_small_icon", Value: "/static/img/favicon.ico", Type: "pwa"}, diff --git a/pkg/recaptcha/recaptcha.go b/pkg/recaptcha/recaptcha.go new file mode 100644 index 0000000..75354bd --- /dev/null +++ b/pkg/recaptcha/recaptcha.go @@ -0,0 +1,182 @@ +package recaptcha + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" +) + +const reCAPTCHALink = "https://www.recaptcha.net/recaptcha/api/siteverify" + +// VERSION the recaptcha api version +type VERSION int8 + +const ( + // V2 recaptcha api v2 + V2 VERSION = iota + // V3 recaptcha api v3, more details can be found here : https://developers.google.com/recaptcha/docs/v3 + V3 + // DefaultTreshold Default minimin score when using V3 api + DefaultTreshold float32 = 0.5 +) + +type reCHAPTCHARequest struct { + Secret string `json:"secret"` + Response string `json:"response"` + RemoteIP string `json:"remoteip,omitempty"` +} + +type reCHAPTCHAResponse struct { + Success bool `json:"success"` + ChallengeTS time.Time `json:"challenge_ts"` + Hostname string `json:"hostname,omitempty"` + ApkPackageName string `json:"apk_package_name,omitempty"` + Action string `json:"action,omitempty"` + Score float32 `json:"score,omitempty"` + ErrorCodes []string `json:"error-codes,omitempty"` +} + +// custom client so we can mock in tests +type netClient interface { + PostForm(url string, formValues url.Values) (resp *http.Response, err error) +} + +// custom clock so we can mock in tests +type clock interface { + Since(t time.Time) time.Duration +} + +type realClock struct { +} + +func (realClock) Since(t time.Time) time.Duration { + return time.Since(t) +} + +// ReCAPTCHA recpatcha holder struct, make adding mocking code simpler +type ReCAPTCHA struct { + client netClient + Secret string + ReCAPTCHALink string + Version VERSION + Timeout time.Duration + horloge clock +} + +// NewReCAPTCHA new ReCAPTCHA instance if version is set to V2 uses recatpcha v2 API, get your secret from https://www.google.com/recaptcha/admin +// if version is set to V2 uses recatpcha v2 API, get your secret from https://g.co/recaptcha/v3 +func NewReCAPTCHA(ReCAPTCHASecret string, version VERSION, timeout time.Duration) (ReCAPTCHA, error) { + if ReCAPTCHASecret == "" { + return ReCAPTCHA{}, fmt.Errorf("recaptcha secret cannot be blank") + } + return ReCAPTCHA{ + client: &http.Client{ + Timeout: timeout, + }, + horloge: &realClock{}, + Secret: ReCAPTCHASecret, + ReCAPTCHALink: reCAPTCHALink, + Timeout: timeout, + Version: version, + }, nil +} + +// Verify returns `nil` if no error and the client solved the challenge correctly +func (r *ReCAPTCHA) Verify(challengeResponse string) error { + body := reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse} + return r.confirm(body, VerifyOption{}) +} + +// VerifyOption verification options expected for the challenge +type VerifyOption struct { + Threshold float32 // ignored in v2 recaptcha + Action string // ignored in v2 recaptcha + Hostname string + ApkPackageName string + ResponseTime time.Duration + RemoteIP string +} + +// VerifyWithOptions returns `nil` if no error and the client solved the challenge correctly and all options are natching +// `Threshold` and `Action` are ignored when using V2 version +func (r *ReCAPTCHA) VerifyWithOptions(challengeResponse string, options VerifyOption) error { + var body reCHAPTCHARequest + if options.RemoteIP == "" { + body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse} + } else { + body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse, RemoteIP: options.RemoteIP} + } + return r.confirm(body, options) +} + +func (r *ReCAPTCHA) confirm(recaptcha reCHAPTCHARequest, options VerifyOption) (Err error) { + Err = nil + var formValues url.Values + if recaptcha.RemoteIP != "" { + formValues = url.Values{"secret": {recaptcha.Secret}, "remoteip": {recaptcha.RemoteIP}, "response": {recaptcha.Response}} + } else { + formValues = url.Values{"secret": {recaptcha.Secret}, "response": {recaptcha.Response}} + } + response, err := r.client.PostForm(r.ReCAPTCHALink, formValues) + if err != nil { + Err = fmt.Errorf("error posting to recaptcha endpoint: '%s'", err) + return + } + defer response.Body.Close() + resultBody, err := ioutil.ReadAll(response.Body) + if err != nil { + Err = fmt.Errorf("couldn't read response body: '%s'", err) + return + } + var result reCHAPTCHAResponse + err = json.Unmarshal(resultBody, &result) + if err != nil { + Err = fmt.Errorf("invalid response body json: '%s'", err) + return + } + + if options.Hostname != "" && options.Hostname != result.Hostname { + Err = fmt.Errorf("invalid response hostname '%s', while expecting '%s'", result.Hostname, options.Hostname) + return + } + + if options.ApkPackageName != "" && options.ApkPackageName != result.ApkPackageName { + Err = fmt.Errorf("invalid response ApkPackageName '%s', while expecting '%s'", result.ApkPackageName, options.ApkPackageName) + return + } + + if options.ResponseTime != 0 { + duration := r.horloge.Since(result.ChallengeTS) + if options.ResponseTime < duration { + Err = fmt.Errorf("time spent in resolving challenge '%fs', while expecting maximum '%fs'", duration.Seconds(), options.ResponseTime.Seconds()) + return + } + } + if r.Version == V3 { + if options.Action != "" && options.Action != result.Action { + Err = fmt.Errorf("invalid response action '%s', while expecting '%s'", result.Action, options.Action) + return + } + if options.Threshold != 0 && options.Threshold >= result.Score { + Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, options.Threshold) + return + } + if options.Threshold == 0 && DefaultTreshold >= result.Score { + Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, DefaultTreshold) + return + } + } + if result.ErrorCodes != nil { + Err = fmt.Errorf("remote error codes: %v", result.ErrorCodes) + return + } + if !result.Success && recaptcha.RemoteIP != "" { + Err = fmt.Errorf("invalid challenge solution or remote IP") + } else if !result.Success { + Err = fmt.Errorf("invalid challenge solution") + } + return +} diff --git a/pkg/serializer/setting.go b/pkg/serializer/setting.go index 8db0827..8a69bd2 100644 --- a/pkg/serializer/setting.go +++ b/pkg/serializer/setting.go @@ -15,6 +15,8 @@ type SiteConfig struct { ShareViewMethod string `json:"share_view_method"` Authn bool `json:"authn"'` User User `json:"user"` + UseReCaptcha bool `json:"captcha_IsUseReCaptcha"` + ReCaptchaKey string `json:"captcha_ReCaptchaKey"` } type task struct { @@ -72,6 +74,8 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response { ShareViewMethod: checkSettingValue(settings, "share_view_method"), Authn: model.IsTrueVal(checkSettingValue(settings, "authn_enabled")), User: userRes, + UseReCaptcha: model.IsTrueVal(checkSettingValue(settings, "captcha_IsUseReCaptcha")), + ReCaptchaKey: checkSettingValue(settings, "captcha_ReCaptchaKey"), }} return res } diff --git a/routers/controllers/site.go b/routers/controllers/site.go index 4793eb4..852341f 100644 --- a/routers/controllers/site.go +++ b/routers/controllers/site.go @@ -23,6 +23,8 @@ func SiteConfig(c *gin.Context) { "home_view_method", "share_view_method", "authn_enabled", + "captcha_IsUseReCaptcha", + "captcha_ReCaptchaKey", ) // 如果已登录,则同时返回用户信息和标签 diff --git a/service/user/login.go b/service/user/login.go index 3578f92..88ef725 100644 --- a/service/user/login.go +++ b/service/user/login.go @@ -69,12 +69,24 @@ func (service *UserResetService) Reset(c *gin.Context) serializer.Response { func (service *UserResetEmailService) Reset(c *gin.Context) serializer.Response { // 检查验证码 isCaptchaRequired := model.IsTrueVal(model.GetSettingByName("forget_captcha")) - if isCaptchaRequired { + useRecaptcha := model.IsTrueVal(model.GetSettingByName("captcha_IsUseReCaptcha")) + recaptchaSecret := model.GetSettingByName("captcha_ReCaptchaSecret") + if isCaptchaRequired && !useRecaptcha { captchaID := util.GetSession(c, "captchaID") util.DeleteSession(c, "captchaID") if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) { return serializer.ParamErr("验证码错误", nil) } + } else if isCaptchaRequired && useRecaptcha { + captcha, err := recaptcha.NewReCAPTCHA(recaptchaSecret, recaptcha.V2, 10*time.Second) + if err != nil { + util.Log().Error(err.Error()) + } + err = captcha.Verify(service.CaptchaCode) + if err != nil { + util.Log().Error(err.Error()) + return serializer.ParamErr("验证失败,请刷新网页后再次验证", nil) + } } // 查找用户 @@ -132,14 +144,27 @@ func (service *Enable2FA) Login(c *gin.Context) serializer.Response { // Login 用户登录函数 func (service *UserLoginService) Login(c *gin.Context) serializer.Response { isCaptchaRequired := model.GetSettingByName("login_captcha") + useRecaptcha := model.GetSettingByName("captcha_IsUseReCaptcha") + recaptchaSecret := model.GetSettingByName("captcha_ReCaptchaSecret") expectedUser, err := model.GetUserByEmail(service.UserName) - if model.IsTrueVal(isCaptchaRequired) { + if (model.IsTrueVal(isCaptchaRequired)) && !(model.IsTrueVal(useRecaptcha)) { + // TODO 验证码校验 captchaID := util.GetSession(c, "captchaID") util.DeleteSession(c, "captchaID") if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) { return serializer.ParamErr("验证码错误", nil) } + } else if (model.IsTrueVal(isCaptchaRequired)) && (model.IsTrueVal(useRecaptcha)) { + captcha, err := recaptcha.NewReCAPTCHA(recaptchaSecret, recaptcha.V2, 10*time.Second) + if err != nil { + util.Log().Error(err.Error()) + } + err = captcha.Verify(service.CaptchaCode) + if err != nil { + util.Log().Error(err.Error()) + return serializer.ParamErr("验证失败,请刷新网页后再次验证", nil) + } } // 一系列校验 diff --git a/service/user/register.go b/service/user/register.go index f5642a4..a69ac7a 100644 --- a/service/user/register.go +++ b/service/user/register.go @@ -5,12 +5,14 @@ import ( "github.com/HFO4/cloudreve/pkg/auth" "github.com/HFO4/cloudreve/pkg/email" "github.com/HFO4/cloudreve/pkg/hashid" + "github.com/HFO4/cloudreve/pkg/recaptcha" "github.com/HFO4/cloudreve/pkg/serializer" "github.com/HFO4/cloudreve/pkg/util" "github.com/gin-gonic/gin" "github.com/mojocn/base64Captcha" "net/url" "strings" + "time" ) // UserRegisterService 管理用户注册的服务 @@ -27,12 +29,24 @@ func (service *UserRegisterService) Register(c *gin.Context) serializer.Response options := model.GetSettingByNames("email_active", "reg_captcha") // 检查验证码 isCaptchaRequired := model.IsTrueVal(options["reg_captcha"]) - if isCaptchaRequired { + useRecaptcha := model.IsTrueVal(model.GetSettingByName("captcha_IsUseReCaptcha")) + recaptchaSecret := model.GetSettingByName("captcha_ReCaptchaSecret") + if isCaptchaRequired && !useRecaptcha { captchaID := util.GetSession(c, "captchaID") util.DeleteSession(c, "captchaID") if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) { return serializer.ParamErr("验证码错误", nil) } + } else if isCaptchaRequired && useRecaptcha { + captcha, err := recaptcha.NewReCAPTCHA(recaptchaSecret, recaptcha.V2, 10*time.Second) + if err != nil { + util.Log().Error(err.Error()) + } + err = captcha.Verify(service.CaptchaCode) + if err != nil { + util.Log().Error(err.Error()) + return serializer.ParamErr("验证失败,请刷新网页后再次验证", nil) + } } // 相关设定