Feat: ReCaptcha support (#292)
* Add custom mysql database port. * Modify: add cloudreve bin file to .gitignore * Feat:增加后端对ReCaptcha的支持 P.S.必须要执行迁移
This commit is contained in:
parent
fa900b166a
commit
e58fb82463
8 changed files with 235 additions and 4 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
# Binaries for programs and plugins
|
||||
cloudreve
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
|
2
assets
2
assets
|
@ -1 +1 @@
|
|||
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"},
|
||||
|
|
182
pkg/recaptcha/recaptcha.go
Normal file
182
pkg/recaptcha/recaptcha.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 相关设定
|
||||
|
|
Loading…
Add table
Reference in a new issue