未验证 提交 e58fb824 编写于 作者: T topjohncian 提交者: GitHub

Feat: ReCaptcha support (#292)

* Add custom mysql database port.

* Modify: add cloudreve bin file to .gitignore

* Feat:增加后端对ReCaptcha的支持
P.S.必须要执行迁移
上级 fa900b16
# Binaries for programs and plugins
cloudreve
*.exe
*.exe~
*.dll
......
Subproject commit 43c9ce1d266050637a247113db54883ce2218291
Subproject commit f544486b6ae2440df197630601b1827ed6977c0b
......@@ -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"},
......
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
}
......@@ -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
}
......@@ -23,6 +23,8 @@ func SiteConfig(c *gin.Context) {
"home_view_method",
"share_view_method",
"authn_enabled",
"captcha_IsUseReCaptcha",
"captcha_ReCaptchaKey",
)
// 如果已登录,则同时返回用户信息和标签
......
......@@ -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)
}
}
// 一系列校验
......
......@@ -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)
}
}
// 相关设定
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册