mirror of https://github.com/cloudreve/Cloudreve
Feat: ReCaptcha support (#292)
* Add custom mysql database port. * Modify: add cloudreve bin file to .gitignore * Feat:增加后端对ReCaptcha的支持 P.S.必须要执行迁移pull/310/head
parent
fa900b166a
commit
e58fb82463
|
@ -1,4 +1,5 @@
|
||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
|
cloudreve
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.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_IsShowSlimeLine", Value: "1", Type: "captcha"},
|
||||||
{Name: "captcha_IsShowSineLine", Value: "0", Type: "captcha"},
|
{Name: "captcha_IsShowSineLine", Value: "0", Type: "captcha"},
|
||||||
{Name: "captcha_CaptchaLen", Value: "6", 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_width", Value: "400", Type: "thumb"},
|
||||||
{Name: "thumb_height", Value: "300", Type: "thumb"},
|
{Name: "thumb_height", Value: "300", Type: "thumb"},
|
||||||
{Name: "pwa_small_icon", Value: "/static/img/favicon.ico", Type: "pwa"},
|
{Name: "pwa_small_icon", Value: "/static/img/favicon.ico", Type: "pwa"},
|
||||||
|
|
|
@ -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"`
|
ShareViewMethod string `json:"share_view_method"`
|
||||||
Authn bool `json:"authn"'`
|
Authn bool `json:"authn"'`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
|
UseReCaptcha bool `json:"captcha_IsUseReCaptcha"`
|
||||||
|
ReCaptchaKey string `json:"captcha_ReCaptchaKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type task struct {
|
type task struct {
|
||||||
|
@ -72,6 +74,8 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response {
|
||||||
ShareViewMethod: checkSettingValue(settings, "share_view_method"),
|
ShareViewMethod: checkSettingValue(settings, "share_view_method"),
|
||||||
Authn: model.IsTrueVal(checkSettingValue(settings, "authn_enabled")),
|
Authn: model.IsTrueVal(checkSettingValue(settings, "authn_enabled")),
|
||||||
User: userRes,
|
User: userRes,
|
||||||
|
UseReCaptcha: model.IsTrueVal(checkSettingValue(settings, "captcha_IsUseReCaptcha")),
|
||||||
|
ReCaptchaKey: checkSettingValue(settings, "captcha_ReCaptchaKey"),
|
||||||
}}
|
}}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ func SiteConfig(c *gin.Context) {
|
||||||
"home_view_method",
|
"home_view_method",
|
||||||
"share_view_method",
|
"share_view_method",
|
||||||
"authn_enabled",
|
"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 {
|
func (service *UserResetEmailService) Reset(c *gin.Context) serializer.Response {
|
||||||
// 检查验证码
|
// 检查验证码
|
||||||
isCaptchaRequired := model.IsTrueVal(model.GetSettingByName("forget_captcha"))
|
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")
|
captchaID := util.GetSession(c, "captchaID")
|
||||||
util.DeleteSession(c, "captchaID")
|
util.DeleteSession(c, "captchaID")
|
||||||
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
||||||
return serializer.ParamErr("验证码错误", nil)
|
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 用户登录函数
|
// Login 用户登录函数
|
||||||
func (service *UserLoginService) Login(c *gin.Context) serializer.Response {
|
func (service *UserLoginService) Login(c *gin.Context) serializer.Response {
|
||||||
isCaptchaRequired := model.GetSettingByName("login_captcha")
|
isCaptchaRequired := model.GetSettingByName("login_captcha")
|
||||||
|
useRecaptcha := model.GetSettingByName("captcha_IsUseReCaptcha")
|
||||||
|
recaptchaSecret := model.GetSettingByName("captcha_ReCaptchaSecret")
|
||||||
expectedUser, err := model.GetUserByEmail(service.UserName)
|
expectedUser, err := model.GetUserByEmail(service.UserName)
|
||||||
|
|
||||||
if model.IsTrueVal(isCaptchaRequired) {
|
if (model.IsTrueVal(isCaptchaRequired)) && !(model.IsTrueVal(useRecaptcha)) {
|
||||||
|
// TODO 验证码校验
|
||||||
captchaID := util.GetSession(c, "captchaID")
|
captchaID := util.GetSession(c, "captchaID")
|
||||||
util.DeleteSession(c, "captchaID")
|
util.DeleteSession(c, "captchaID")
|
||||||
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
||||||
return serializer.ParamErr("验证码错误", nil)
|
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/auth"
|
||||||
"github.com/HFO4/cloudreve/pkg/email"
|
"github.com/HFO4/cloudreve/pkg/email"
|
||||||
"github.com/HFO4/cloudreve/pkg/hashid"
|
"github.com/HFO4/cloudreve/pkg/hashid"
|
||||||
|
"github.com/HFO4/cloudreve/pkg/recaptcha"
|
||||||
"github.com/HFO4/cloudreve/pkg/serializer"
|
"github.com/HFO4/cloudreve/pkg/serializer"
|
||||||
"github.com/HFO4/cloudreve/pkg/util"
|
"github.com/HFO4/cloudreve/pkg/util"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mojocn/base64Captcha"
|
"github.com/mojocn/base64Captcha"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserRegisterService 管理用户注册的服务
|
// UserRegisterService 管理用户注册的服务
|
||||||
|
@ -27,12 +29,24 @@ func (service *UserRegisterService) Register(c *gin.Context) serializer.Response
|
||||||
options := model.GetSettingByNames("email_active", "reg_captcha")
|
options := model.GetSettingByNames("email_active", "reg_captcha")
|
||||||
// 检查验证码
|
// 检查验证码
|
||||||
isCaptchaRequired := model.IsTrueVal(options["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")
|
captchaID := util.GetSession(c, "captchaID")
|
||||||
util.DeleteSession(c, "captchaID")
|
util.DeleteSession(c, "captchaID")
|
||||||
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
if captchaID == nil || !base64Captcha.VerifyCaptcha(captchaID.(string), service.CaptchaCode) {
|
||||||
return serializer.ParamErr("验证码错误", nil)
|
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…
Reference in New Issue