Merge pull request #64 from mc0814/master

add login captcha
This commit is contained in:
zhenorzz 2023-08-17 09:16:31 +08:00 committed by GitHub
commit 76ccb466aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1447 additions and 8 deletions

View File

@ -11,9 +11,11 @@ import (
"errors"
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/wenlng/go-captcha/captcha"
"github.com/zhenorzz/goploy/cmd/server/api"
"github.com/zhenorzz/goploy/cmd/server/api/middleware"
"github.com/zhenorzz/goploy/config"
"github.com/zhenorzz/goploy/internal/cache"
"github.com/zhenorzz/goploy/internal/media"
"github.com/zhenorzz/goploy/internal/model"
"github.com/zhenorzz/goploy/internal/server"
@ -21,6 +23,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@ -40,6 +43,9 @@ func (u User) Handler() []server.Route {
server.NewRoute("/user/remove", http.MethodDelete, u.Remove).Permissions(config.DeleteMember).LogFunc(middleware.AddOPLog),
server.NewWhiteRoute("/user/mediaLogin", http.MethodPost, u.MediaLogin).LogFunc(middleware.AddLoginLog),
server.NewWhiteRoute("/user/getMediaLoginUrl", http.MethodGet, u.GetMediaLoginUrl),
server.NewWhiteRoute("/user/getCaptchaConfig", http.MethodGet, u.GetCaptchaConfig),
server.NewWhiteRoute("/user/getCaptcha", http.MethodGet, u.GetCaptcha),
server.NewWhiteRoute("/user/checkCaptcha", http.MethodPost, u.CheckCaptcha),
}
}
@ -52,14 +58,30 @@ func (u User) Handler() []server.Route {
// @Router /user/login [post]
func (User) Login(gp *server.Goploy) server.Response {
type ReqData struct {
Account string `json:"account" validate:"required,min=1,max=25"`
Password string `json:"password" validate:"required,password"`
Account string `json:"account" validate:"required,min=1,max=25"`
Password string `json:"password" validate:"required,password"`
CaptchaKey string `json:"captchaKey" validate:"omitempty"`
}
var reqData ReqData
if err := gp.Decode(&reqData); err != nil {
return response.JSON{Code: response.IllegalParam, Message: err.Error()}
}
userCache := cache.GetUserCache()
if config.Toml.Captcha.Enabled && userCache.IsShowCaptcha(reqData.Account) {
captchaCache := cache.GetCaptchaCache()
if !captchaCache.IsChecked(reqData.CaptchaKey) {
return response.JSON{Code: response.Error, Message: "Captcha error, please check captcha again"}
}
// captcha should be deleted after check
captchaCache.Delete(reqData.CaptchaKey)
}
if userCache.IsLock(reqData.Account) {
return response.JSON{Code: response.Error, Message: "Your account has been locked, please retry login in 15 minutes"}
}
userData, err := model.User{Account: reqData.Account}.GetDataByAccount()
if errors.Is(err, sql.ErrNoRows) {
return response.JSON{Code: response.Error, Message: "We couldn't verify your identity. Please confirm if your username and password are correct."}
@ -108,10 +130,17 @@ func (User) Login(gp *server.Goploy) server.Response {
}
} else {
if err := userData.Validate(reqData.Password); err != nil {
errorTimes := userCache.IncErrorTimes(reqData.Account, cache.UserCacheExpireTime, cache.UserCacheShowCaptchaTime)
// error times over 5 times, then lock the account 15 minutes
if errorTimes >= cache.UserCacheMaxErrorTimes {
userCache.LockAccount(reqData.Account, cache.UserCacheLockTime)
}
return response.JSON{Code: response.Deny, Message: err.Error()}
}
}
userCache.DeleteShowCaptcha(reqData.Account)
if userData.State == model.Disable {
return response.JSON{Code: response.AccountDisabled, Message: "Account is disabled"}
}
@ -566,3 +595,97 @@ func (User) GetMediaLoginUrl(gp *server.Goploy) server.Response {
}{Dingtalk: dingtalk, Feishu: feishu},
}
}
func (User) GetCaptchaConfig(gp *server.Goploy) server.Response {
return response.JSON{
Data: struct {
Enabled bool `json:"enabled"`
}{
Enabled: config.Toml.Captcha.Enabled,
},
}
}
func (User) GetCaptcha(gp *server.Goploy) server.Response {
type ReqData struct {
Language string `schema:"language" validate:"required"`
}
var reqData ReqData
if err := gp.Decode(&reqData); err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
}
capt := captcha.GetCaptcha()
if reqData.Language == "zh-cn" {
chars := captcha.GetCaptchaDefaultChars()
_ = capt.SetRangChars(*chars)
} else {
chars := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_ = capt.SetRangChars(strings.Split(chars, ""))
}
dots, b64, tb64, key, err := capt.Generate()
if err != nil {
return response.JSON{Code: response.AccountDisabled, Message: "generate captcha fail, error msg:" + err.Error()}
}
captchaCache := cache.GetCaptchaCache()
captchaCache.Set(key, dots, 2*time.Minute)
return response.JSON{
Data: struct {
Base64 string `json:"base64"`
ThumbBase64 string `json:"thumbBase64"`
Key string `json:"key"`
}{
Base64: b64,
ThumbBase64: tb64,
Key: key,
},
}
}
func (User) CheckCaptcha(gp *server.Goploy) server.Response {
type ReqData struct {
Key string `json:"key" validate:"required"`
Dots []int64 `json:"dots" validate:"required"`
RedirectUri string `json:"redirectUri" validate:"omitempty"`
}
var reqData ReqData
if err := gp.Decode(&reqData); err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
}
captchaCache := cache.GetCaptchaCache()
dotsCache, ok := captchaCache.Get(reqData.Key)
if !ok {
return response.JSON{Code: response.Error, Message: "Illegal key, please refresh the captcha again"}
}
dots, _ := dotsCache.(map[int]captcha.CharDot)
check := false
if (len(dots) * 2) == len(reqData.Dots) {
for i, dot := range dots {
j := i * 2
k := i*2 + 1
sx, _ := strconv.ParseFloat(fmt.Sprintf("%v", reqData.Dots[j]), 64)
sy, _ := strconv.ParseFloat(fmt.Sprintf("%v", reqData.Dots[k]), 64)
check = captcha.CheckPointDistWithPadding(int64(sx), int64(sy), int64(dot.Dx), int64(dot.Dy), int64(dot.Width), int64(dot.Height), 15)
if !check {
break
}
}
}
if !check {
return response.JSON{Code: response.Error, Message: "check captcha fail"}
}
// set captcha key checked for login verify
captchaCache.Set(reqData.Key, true, 2*time.Minute)
return response.JSON{}
}

View File

@ -29,6 +29,8 @@ type Config struct {
Dingtalk DingtalkConfig `toml:"dingtalk"`
Feishu FeishuConfig `toml:"feishu"`
CORS CORSConfig `toml:"cors"`
Captcha CaptchaConfig `toml:"captcha"`
Cache CacheConfig `toml:"cache"`
}
type APPConfig struct {
@ -91,6 +93,14 @@ type FeishuConfig struct {
AppSecret string `toml:"appSecret"`
}
type CaptchaConfig struct {
Enabled bool `toml:"enabled"`
}
type CacheConfig struct {
Type string `toml:"type"`
}
var Toml Config
func InitToml() {

3
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/pkg/sftp v1.13.1
github.com/sirupsen/logrus v1.9.3
github.com/vearutop/statigz v1.1.8
github.com/wenlng/go-captcha v1.2.5
golang.org/x/crypto v0.1.0
gopkg.in/go-playground/validator.v9 v9.31.0
)
@ -25,6 +26,7 @@ require (
require (
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect
github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/fs v0.1.0 // indirect
@ -32,6 +34,7 @@ require (
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/sys v0.1.0 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
)

26
go.sum
View File

@ -21,6 +21,8 @@ github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gG
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
@ -62,26 +64,50 @@ github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiU
github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vearutop/statigz v1.1.8 h1:IJgQHx6EomuYOYd2TzFt3haP+BIzV471zn7aepRiLHA=
github.com/vearutop/statigz v1.1.8/go.mod h1:pfzrpvgLRnFeSVZd9iUYrpYDLqbV+RgeCfizr3ZFf44=
github.com/wenlng/go-captcha v1.2.5 h1:zA0/fovEl9oAhSg+KwHBwmq99GeeAXknWx6wYKjhjTg=
github.com/wenlng/go-captcha v1.2.5/go.mod h1:QgPgpEURSa37gF3GtojNoNRwbMwuatSBx5NXrzASOb0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=

View File

@ -47,4 +47,10 @@ appSecret = ''
[feishu]
appKey = ''
appSecret = ''
appSecret = ''
[captcha]
enabled = false
[cache]
type = 'memory'

10
internal/cache/captcha.go vendored Normal file
View File

@ -0,0 +1,10 @@
package cache
import "time"
type Captcha interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{}, ttl time.Duration)
Delete(key string)
IsChecked(key string) bool
}

8
internal/cache/dingtalk.go vendored Normal file
View File

@ -0,0 +1,8 @@
package cache
import "time"
type DingtalkAccessToken interface {
Get(key string) (string, bool)
Set(key string, value string, ttl time.Duration)
}

37
internal/cache/factory.go vendored Normal file
View File

@ -0,0 +1,37 @@
package cache
import (
"github.com/zhenorzz/goploy/config"
"github.com/zhenorzz/goploy/internal/cache/memory"
)
const MemoryCache = "memory"
var cacheType = config.Toml.Cache.Type
func GetUserCache() User {
switch cacheType {
case MemoryCache:
return memory.GetUserCache()
default:
return memory.GetUserCache()
}
}
func GetCaptchaCache() Captcha {
switch cacheType {
case MemoryCache:
return memory.GetCaptchaCache()
default:
return memory.GetCaptchaCache()
}
}
func GetDingTalkAccessTokenCache() DingtalkAccessToken {
switch cacheType {
case MemoryCache:
return memory.GetDingTalkAccessTokenCache()
default:
return memory.GetDingTalkAccessTokenCache()
}
}

80
internal/cache/memory/captcha.go vendored Normal file
View File

@ -0,0 +1,80 @@
package memory
import (
"sync"
"time"
)
type CaptchaCache struct {
data map[string]captcha
sync.RWMutex
}
type captcha struct {
dots interface{}
expireIn time.Time
}
var captchaCache = &CaptchaCache{
data: make(map[string]captcha),
}
func (c *CaptchaCache) Get(key string) (interface{}, bool) {
c.RLock()
defer c.RUnlock()
v, ok := c.data[key]
if !ok {
return nil, false
}
if !v.expireIn.IsZero() && v.expireIn.After(time.Now()) {
return v.dots, true
}
return nil, false
}
func (c *CaptchaCache) Set(key string, value interface{}, ttl time.Duration) {
c.Lock()
defer c.Unlock()
var expireIn time.Time
if ttl > 0 {
expireIn = time.Now().Add(ttl)
}
c.data[key] = captcha{
dots: value,
expireIn: expireIn,
}
time.AfterFunc(ttl, func() {
delete(c.data, key)
})
}
func (c *CaptchaCache) Delete(key string) {
c.Lock()
defer c.Unlock()
delete(c.data, key)
}
func (c *CaptchaCache) IsChecked(key string) bool {
if key == "" {
return false
}
if check, ok := c.Get(key); ok {
_check, _ := check.(bool)
return _check
}
return false
}
func GetCaptchaCache() *CaptchaCache {
return captchaCache
}

60
internal/cache/memory/dingtalk.go vendored Normal file
View File

@ -0,0 +1,60 @@
package memory
import (
"sync"
"time"
)
type AccessTokenCache struct {
data map[string]accessToken
mutex sync.RWMutex
}
type accessToken struct {
accessToken string
expireIn time.Time
}
var accessTokenCache = &AccessTokenCache{
data: make(map[string]accessToken),
}
func (ac *AccessTokenCache) Get(key string) (string, bool) {
ac.mutex.RLock()
defer ac.mutex.RUnlock()
v, ok := ac.data[key]
if !ok {
return "", false
}
if !v.expireIn.IsZero() && v.expireIn.After(time.Now()) {
return v.accessToken, true
}
return "", false
}
func (ac *AccessTokenCache) Set(key string, value string, ttl time.Duration) {
ac.mutex.Lock()
defer ac.mutex.Unlock()
var expireIn time.Time
if ttl > 0 {
expireIn = time.Now().Add(ttl)
}
ac.data[key] = accessToken{
accessToken: value,
expireIn: expireIn,
}
time.AfterFunc(ttl, func() {
delete(ac.data, key)
})
}
func GetDingTalkAccessTokenCache() *AccessTokenCache {
return accessTokenCache
}

127
internal/cache/memory/user.go vendored Normal file
View File

@ -0,0 +1,127 @@
package memory
import (
"sync"
"time"
)
const (
UserCacheKey = "login_error_times_"
UserCacheLockKey = "login_lock_"
UserCacheShowCaptchaKey = "login_show_captcha_"
)
type UserCache struct {
data map[string]user
sync.RWMutex
}
type user struct {
times int
expireIn time.Time
}
var userCache = &UserCache{
data: make(map[string]user),
}
func (uc *UserCache) IncErrorTimes(account string, expireTime time.Duration, showCaptchaTime time.Duration) int {
uc.Lock()
defer uc.Unlock()
cacheKey := getCacheKey(account)
times := 0
v, ok := uc.data[cacheKey]
if ok && !v.expireIn.IsZero() && v.expireIn.After(time.Now()) {
times = v.times
}
times += 1
uc.data[cacheKey] = user{
times: times,
expireIn: time.Now().Add(expireTime),
}
time.AfterFunc(expireTime, func() {
delete(uc.data, cacheKey)
})
// show captcha
showCaptchaKey := getShowCaptchaKey(account)
uc.data[showCaptchaKey] = user{
times: 1,
expireIn: time.Now().Add(showCaptchaTime),
}
time.AfterFunc(showCaptchaTime, func() {
delete(uc.data, showCaptchaKey)
})
return times
}
func (uc *UserCache) LockAccount(account string, lockTime time.Duration) {
uc.Lock()
defer uc.Unlock()
lockKey := getLockKey(account)
uc.data[lockKey] = user{
times: 1,
expireIn: time.Now().Add(lockTime),
}
time.AfterFunc(lockTime, func() {
delete(uc.data, lockKey)
})
cacheKey := getCacheKey(account)
_, ok := uc.data[cacheKey]
if ok {
delete(uc.data, cacheKey)
}
}
func (uc *UserCache) IsLock(account string) bool {
uc.RLock()
defer uc.RUnlock()
lockKey := getLockKey(account)
v, ok := uc.data[lockKey]
return ok && !v.expireIn.IsZero() && v.expireIn.After(time.Now()) && v.times > 0
}
func (uc *UserCache) IsShowCaptcha(account string) bool {
uc.RLock()
defer uc.RUnlock()
showCaptchaKey := getShowCaptchaKey(account)
v, ok := uc.data[showCaptchaKey]
return ok && !v.expireIn.IsZero() && v.expireIn.After(time.Now()) && v.times > 0
}
func (uc *UserCache) DeleteShowCaptcha(account string) {
uc.Lock()
defer uc.Unlock()
delete(uc.data, getShowCaptchaKey(account))
}
func getCacheKey(account string) string {
return UserCacheKey + account
}
func getLockKey(account string) string {
return UserCacheLockKey + account
}
func getShowCaptchaKey(account string) string {
return UserCacheShowCaptchaKey + account
}
func GetUserCache() *UserCache {
return userCache
}

18
internal/cache/user.go vendored Normal file
View File

@ -0,0 +1,18 @@
package cache
import "time"
type User interface {
IncErrorTimes(account string, expireTime time.Duration, showCaptchaTime time.Duration) int
LockAccount(account string, lockTime time.Duration)
IsLock(account string) bool
IsShowCaptcha(account string) bool
DeleteShowCaptcha(account string)
}
const (
UserCacheMaxErrorTimes = 5
UserCacheExpireTime = 5 * time.Minute
UserCacheLockTime = 15 * time.Minute
UserCacheShowCaptchaTime = 15 * time.Minute
)

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"github.com/zhenorzz/goploy/config"
"github.com/zhenorzz/goploy/internal/cache"
"github.com/zhenorzz/goploy/internal/media/dingtalk/api"
"github.com/zhenorzz/goploy/internal/media/dingtalk/api/access_token"
"github.com/zhenorzz/goploy/internal/media/dingtalk/api/contact"
@ -21,7 +22,7 @@ type Dingtalk struct {
Key string
Secret string
Client *http.Client
Cache *AccessTokenCache
Cache cache.DingtalkAccessToken
Method string
Api string
Query url.Values
@ -33,7 +34,7 @@ func (d *Dingtalk) Login(authCode string, _ string) (string, error) {
d.Key = config.Toml.Dingtalk.AppKey
d.Secret = config.Toml.Dingtalk.AppSecret
d.Client = &http.Client{}
d.Cache = GetCache()
d.Cache = cache.GetDingTalkAccessTokenCache()
userAccessTokenInfo, err := d.GetUserAccessToken(authCode)
if err != nil {

View File

@ -19,6 +19,7 @@ export class Login extends Request {
public param: {
account: string
password: string
captchaKey: string
}
public declare datagram: {
namespaceList: { id: number; name: string; roleId: number }[]
@ -172,3 +173,41 @@ export class UserChangePassword extends Request {
this.param = param
}
}
export class GetCaptchaConfig extends Request {
readonly url = '/user/getCaptchaConfig'
readonly method = 'get'
public declare datagram: {
enabled: boolean
}
}
export class GetCaptcha extends Request {
readonly url = '/user/getCaptcha'
readonly method = 'get'
public param: {
language: any
}
public declare datagram: {
base64: string
thumbBase64: string
key: string
}
constructor(param: GetCaptcha['param']) {
super()
this.param = param
}
}
export class CheckCaptcha extends Request {
readonly url = '/user/checkCaptcha'
readonly method = 'post'
public param: {
dots: number[]
key: string
}
constructor(param: CheckCaptcha['param']) {
super()
this.param = param
}
}

View File

@ -404,5 +404,15 @@
],
"removeProjectTaskTips": "This action will delete the crontab task in {projectName}, continue?",
"publishCommitTips": "This action will rebuild {commit}, continue?"
},
"loginPage": {
"captchaDefault": "Click on the button for check captcha",
"captchaCheck": "Checking captcha...",
"captchaError": "Check captcha fail",
"captchaOver": "Too many clicks<em>retry</em>",
"captchaSuccess": "Captcha passed",
"captchaRetry": "Click to retry",
"captchaTips": "Please click",
"captchaInOrder": "in order"
}
}

View File

@ -374,5 +374,15 @@
"reviewStateOption": ["待审", "已审", "拒审"],
"removeProjectTaskTips": "此操作删除{projectName}的定时任务, 是否继续?",
"publishCommitTips": "此操作将重新构建{commit}, 是否继续?"
},
"loginPage": {
"captchaDefault": "点击按键进行人机验证",
"captchaCheck": "正在进行人机验证...",
"captchaError": "人机验证失败",
"captchaOver": "点击次数过多",
"captchaSuccess": "人机验证已通过",
"captchaRetry": "点击重试",
"captchaTips": "请在下图",
"captchaInOrder": "依次点击"
}
}

View File

@ -31,9 +31,13 @@ const mutations: MutationTree<UserState> = {
const actions: ActionTree<UserState, RootState> = {
// user login
login(_, userInfo) {
const { account, password } = userInfo
const { account, password, captchaKey } = userInfo
return new Promise((resolve, reject) => {
new Login({ account: account.trim(), password: password })
new Login({
account: account.trim(),
password: password,
captchaKey: captchaKey,
})
.request()
.then((response) => {
const { data } = response

View File

@ -0,0 +1,389 @@
<template>
<div class="wg-cap-wrap">
<div class="wg-cap-wrap__header">
<span
>{{ $t('loginPage.captchaTips')
}}<em>{{ $t('loginPage.captchaInOrder') }}</em></span
>
<img
v-if="thumbBase64Code"
class="wg-cap-wrap__thumb"
:src="thumbBase64Code"
alt=" "
/>
</div>
<div class="wg-cap-wrap__body" :style="style">
<img
v-if="imageBase64Code"
class="wg-cap-wrap__picture"
:src="imageBase64Code"
alt=" "
@click="handleClickPos($event)"
/>
<img
class="wg-cap-wrap__loading"
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBzdHlsZT0ibWFyZ2luOiBhdXRvOyBiYWNrZ3JvdW5kOiByZ2JhKDI0MSwgMjQyLCAyNDMsIDApOyBkaXNwbGF5OiBibG9jazsgc2hhcGUtcmVuZGVyaW5nOiBhdXRvOyIgd2lkdGg9IjY0cHgiIGhlaWdodD0iNjRweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjM2LjgxMDEiIHI9IjEzIiBmaWxsPSIjM2U3Y2ZmIj4KICAgIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9ImN5IiBkdXI9IjFzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgY2FsY01vZGU9InNwbGluZSIga2V5U3BsaW5lcz0iMC40NSAwIDAuOSAwLjU1OzAgMC40NSAwLjU1IDAuOSIga2V5VGltZXM9IjA7MC41OzEiIHZhbHVlcz0iMjM7Nzc7MjMiPjwvYW5pbWF0ZT4KICA8L2NpcmNsZT4KPC9zdmc+"
alt="正在加载中..."
/>
<div v-for="(dot, key) in dots" :key="key">
<div
class="wg-cap-wrap__dot"
:style="`top: ${dot.y}px; left:${dot.x}px;`"
>
<span>{{ dot.index }}</span>
</div>
</div>
</div>
<div class="wg-cap-wrap__footer">
<div class="wg-cap-wrap__ico">
<img
src="data:image/svg+xml;base64,PHN2ZyB0PSIxNjI2NjE0NDM5NDIzIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9Ijg2NzUiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNNTEyIDIzLjI3MjcyN2MyNjkuOTE3MDkxIDAgNDg4LjcyNzI3MyAyMTguODEwMTgyIDQ4OC43MjcyNzMgNDg4LjcyNzI3M2E0ODYuNjMyNzI3IDQ4Ni42MzI3MjcgMCAwIDEtODQuOTQ1NDU1IDI3NS40MDk0NTUgNDYuNTQ1NDU1IDQ2LjU0NTQ1NSAwIDAgMS03Ni44NDY1NDUtNTIuNTI2NTQ2QTM5My41NDE4MTggMzkzLjU0MTgxOCAwIDAgMCA5MDcuNjM2MzY0IDUxMmMwLTIxOC41MDc2MzYtMTc3LjEyODcyNy0zOTUuNjM2MzY0LTM5NS42MzYzNjQtMzk1LjYzNjM2NFMxMTYuMzYzNjM2IDI5My40OTIzNjQgMTE2LjM2MzYzNiA1MTJzMTc3LjEyODcyNyAzOTUuNjM2MzY0IDM5NS42MzYzNjQgMzk1LjYzNjM2NGEzOTUuMTcwOTA5IDM5NS4xNzA5MDkgMCAwIDAgMTI1LjQ0LTIwLjI5MzgxOSA0Ni41NDU0NTUgNDYuNTQ1NDU1IDAgMCAxIDI5LjQ4NjU0NSA4OC4yOTY3MjhBNDg4LjI2MTgxOCA0ODguMjYxODE4IDAgMCAxIDUxMiAxMDAwLjcyNzI3M0MyNDIuMDgyOTA5IDEwMDAuNzI3MjczIDIzLjI3MjcyNyA3ODEuOTE3MDkxIDIzLjI3MjcyNyA1MTJTMjQyLjA4MjkwOSAyMy4yNzI3MjcgNTEyIDIzLjI3MjcyN3ogbS0xMTUuMiAzMDcuNzEyTDUxMiA0NDYuMTM4MTgybDExNS4yLTExNS4yYTQ2LjU0NTQ1NSA0Ni41NDU0NTUgMCAxIDEgNjUuODE1MjczIDY1Ljg2MTgxOEw1NzcuODYxODE4IDUxMmwxMTUuMiAxMTUuMmE0Ni41NDU0NTUgNDYuNTQ1NDU1IDAgMSAxLTY1Ljg2MTgxOCA2NS44MTUyNzNMNTEyIDU3Ny44NjE4MThsLTExNS4yIDExNS4yYTQ2LjU0NTQ1NSA0Ni41NDU0NTUgMCAxIDEtNjUuODE1MjczLTY1Ljg2MTgxOEw0NDYuMTM4MTgyIDUxMmwtMTE1LjItMTE1LjJhNDYuNTQ1NDU1IDQ2LjU0NTQ1NSAwIDEgMSA2NS44NjE4MTgtNjUuODE1MjczeiIgcC1pZD0iODY3NiIgZmlsbD0iIzcwNzA3MCI+PC9wYXRoPjwvc3ZnPg=="
alt="关闭"
@click="handleCloseEvent"
/>
<img
src="data:image/svg+xml;base64,PHN2ZyB0PSIxNjI2NjE0NDk5NjM4IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEzNjAiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNMTg3LjQ1NiA0MjUuMDI0YTMzNiAzMzYgMCAwIDAgMzY4LjM4NCA0MjAuMjI0IDQ4IDQ4IDAgMCAxIDEyLjU0NCA5NS4xNjggNDMyIDQzMiAwIDAgMS00NzMuNjY0LTU0MC4xNmwtNTcuMjgtMTUuMzZhMTIuOCAxMi44IDAgMCAxLTYuMjcyLTIwLjkyOGwxNTkuMTY4LTE3OS40NTZhMTIuOCAxMi44IDAgMCAxIDIyLjE0NCA1Ljg4OGw0OC4wNjQgMjM1LjA3MmExMi44IDEyLjggMCAwIDEtMTUuODA4IDE0LjkxMmwtNTcuMjgtMTUuMzZ6TTgzNi40OCA1OTkuMDRhMzM2IDMzNiAwIDAgMC0zNjguMzg0LTQyMC4yMjQgNDggNDggMCAxIDEtMTIuNTQ0LTk1LjE2OCA0MzIgNDMyIDAgMCAxIDQ3My42NjQgNTQwLjE2bDU3LjI4IDE1LjM2YTEyLjggMTIuOCAwIDAgMSA2LjI3MiAyMC45MjhsLTE1OS4xNjggMTc5LjQ1NmExMi44IDEyLjggMCAwIDEtMjIuMTQ0LTUuODg4bC00OC4wNjQtMjM1LjA3MmExMi44IDEyLjggMCAwIDEgMTUuODA4LTE0LjkxMmw1Ny4yOCAxNS4zNnoiIGZpbGw9IiM3MDcwNzAiIHAtaWQ9IjEzNjEiPjwvcGF0aD48L3N2Zz4="
alt="刷新"
@click="handleRefreshEvent"
/>
</div>
<div class="wg-cap-wrap__btn">
<button @click="handleConfirmEvent">{{ $t('confirm') }}</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue'
const props = defineProps({
modelValue: Boolean,
width: {
type: String,
default: '',
},
height: {
type: String,
default: '',
},
calcPosType: {
type: String,
default: 'dom',
validator: (value: string) => ['dom', 'screen'].includes(value),
},
maxDot: {
type: Number,
default: 5,
},
imageBase64: {
type: String,
default: '',
},
thumbBase64: {
type: String,
default: '',
},
})
const dots = ref<{ x: number; y: number; index: number }[]>([])
const imageBase64Code = ref('')
const thumbBase64Code = ref('')
watch(
() => {
return props.modelValue
},
() => {
dots.value = []
imageBase64Code.value = ''
thumbBase64Code.value = ''
}
)
watch(
() => {
return props.imageBase64
},
(val: string) => {
dots.value = []
imageBase64Code.value = val
}
)
watch(
() => {
return props.thumbBase64
},
(val: string) => {
dots.value = []
thumbBase64Code.value = val
}
)
const style = computed(() => {
return `width:${props.width}; height:${props.height};`
})
const emit = defineEmits(['close', 'refresh', 'confirm'])
/**
* @Description: 处理关闭事件
*/
function handleCloseEvent() {
emit('close')
dots.value = []
imageBase64Code.value = ''
thumbBase64Code.value = ''
}
/**
* @Description: 处理刷新事件
*/
function handleRefreshEvent() {
dots.value = []
emit('refresh')
}
/**
* @Description: 处理确认事件
*/
function handleConfirmEvent() {
emit('confirm', dots.value)
}
/**
* @Description: 处理dot
* @param ev
*/
function handleClickPos(ev: any) {
if (dots.value.length >= props.maxDot) {
return
}
const e = ev || window.event
e.preventDefault()
const dom = e.currentTarget
const { domX, domY } = getDomXY(dom)
// ===============================================
// @notice getDomXY 使 calcLocationLeft calcLocationTop
// const domX = calcLocationLeft(dom)
// const domY = calcLocationTop(dom)
// ===============================================
let mouseX =
navigator.appName === 'Netscape' ? e.pageX : e.x + document.body.offsetTop
let mouseY =
navigator.appName === 'Netscape' ? e.pageY : e.y + document.body.offsetTop
if (props.calcPosType === 'screen') {
mouseX = navigator.appName === 'Netscape' ? e.clientX : e.x
mouseY = navigator.appName === 'Netscape' ? e.clientY : e.y
}
//
const xPos = mouseX - domX
const yPos = mouseY - domY
//
const xp = parseInt(xPos.toString())
const yp = parseInt(yPos.toString())
//
dots.value.push({
x: xp - 11,
y: yp - 11,
index: dots.value.length + 1,
})
return false
}
/**
* @Description: 找到元素的屏幕位置
* @param dom
*/
function getDomXY(dom: any) {
let x = 0
let y = 0
if (dom.getBoundingClientRect) {
let box = dom.getBoundingClientRect()
let D = document.documentElement
x =
box.left + Math.max(D.scrollLeft, document.body.scrollLeft) - D.clientLeft
y = box.top + Math.max(D.scrollTop, document.body.scrollTop) - D.clientTop
} else {
while (dom !== document.body) {
x += dom.offsetLeft
y += dom.offsetTop
dom = dom.offsetParent
}
}
return {
domX: x,
domY: y,
}
}
</script>
<style>
.wg-cap-wrap {
width: 100%;
height: 100%;
background: #ffffff;
-webkit-border-radius: 10px;
-moz-border-radius: 10px;
border-radius: 10px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.wg-cap-wrap__header {
height: 50px;
width: 100%;
font-size: 15px;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
}
.wg-cap-wrap__header span {
padding-right: 5px;
}
.wg-cap-wrap__header span em {
padding: 0 3px;
font-weight: bold;
color: #3e7cff;
font-style: normal;
}
.wg-cap-wrap__header .wg-cap-wrap__image {
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
overflow: hidden;
text-align: center;
line-height: 1;
}
.wg-cap-wrap__header .wg-cap-wrap__thumb {
min-width: 150px;
text-align: center;
line-height: 1;
max-height: 100%;
}
.wg-cap-wrap__header .wg-cap-wrap__thumb.wg-cap-wrap__hidden {
display: none;
}
.wg-cap-wrap__body {
position: relative;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
background: #34383e;
margin: auto;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
overflow: hidden;
}
.wg-cap-wrap__body .wg-cap-wrap__picture {
position: relative;
z-index: 10;
width: 100%;
/*height: 100%;*/
/*max-width: 100%;*/
/*max-height: 100%;*/
/*object-fit: cover;*/
/*text-align: center;*/
}
.wg-cap-wrap__body .wg-cap-wrap__picture.wg-cap-wrap__hidden {
display: none;
}
.wg-cap-wrap__body .wg-cap-wrap__loading {
position: absolute;
z-index: 9;
top: 50%;
left: 50%;
width: 68px;
height: 68px;
margin-left: -34px;
margin-top: -34px;
line-height: 68px;
text-align: center;
}
.wg-cap-wrap__body .wg-cap-wrap__dot {
position: absolute;
z-index: 10;
width: 22px;
height: 22px;
color: #cedffe;
background: #3e7cff;
border: 2px solid #f7f9fb;
line-height: 20px;
text-align: center;
-webkit-border-radius: 22px;
-moz-border-radius: 22px;
border-radius: 22px;
cursor: default;
}
.wg-cap-wrap__footer {
width: 100%;
height: 40px;
color: #34383e;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
padding-top: 15px;
}
.wg-cap-wrap__footer .wg-cap-wrap__ico {
flex: 1;
}
.wg-cap-wrap__footer .wg-cap-wrap__ico img {
width: 24px;
height: 24px;
color: #34383e;
margin: 0 5px;
cursor: pointer;
}
.wg-cap-wrap__footer .wg-cap-wrap__btn {
width: 120px;
height: 40px;
}
.wg-cap-wrap__footer .wg-cap-wrap__btn button {
width: 100%;
height: 40px;
letter-spacing: 2px;
text-align: center;
padding: 9px 15px;
font-size: 15px;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
color: #fff;
background-color: #409eff;
border: 1px solid #409eff;
-webkit-appearance: none;
box-sizing: border-box;
outline: none;
margin: 0;
transition: 0.1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
}
.wg-cap-wrap__footer .wg-cap-wrap__btn button:hover {
background: #66b1ff;
border-color: #66b1ff;
color: #fff;
}
</style>

View File

@ -0,0 +1,392 @@
<template>
<div class="wg-cap-btn" :style="style">
<div class="wg-cap-btn__inner" :class="activeClass">
<!-- wg-cap-active__default wg-cap-active__error wg-cap-active__over wg-cap-active__success -->
<el-popover
ref="popoverRef"
placement="top"
trigger="click"
:width="326"
@before-leave="handleCloseEvent"
>
<go-captcha
v-model="popoverVisible"
width="300px"
height="240px"
:max-dot="maxDot"
:image-base64="imageBase64"
:thumb-base64="thumbBase64"
@close="handleCloseEvent"
@refresh="handleRefreshEvent"
@confirm="handleConfirmEvent"
/>
<template #reference>
<div
v-if="captchaStatus === 'default'"
class="wg-cap-state__default"
@click="handleBtnEvent"
>
<!-- init -->
<div class="wg-cap-state__inner">
<div class="wg-cap-btn__ico wg-cap-btn__verify">
<img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSLlm77lsYJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgdmlld0JveD0iMCAwIDIwMCAyMDAiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDIwMCAyMDA7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojM0U3Q0ZGO30KCS5zdDF7ZmlsbDojRkZGRkZGO30KPC9zdHlsZT4KPGNpcmNsZSBjbGFzcz0ic3QwIiBjeD0iMTAwIiBjeT0iMTAwIiByPSI5Ni4zIi8+CjxwYXRoIGNsYXNzPSJzdDEiIGQ9Ik0xNDAuOCw2NC40bC0zOS42LTExLjloLTIuNEw1OS4yLDY0LjRjLTEuNiwwLjgtMi44LDIuNC0yLjgsNHYyNC4xYzAsMjUuMywxNS44LDQ1LjksNDIuMyw1NC42CgljMC40LDAsMC44LDAuNCwxLjIsMC40YzAuNCwwLDAuOCwwLDEuMi0wLjRjMjYuNS04LjcsNDIuMy0yOC45LDQyLjMtNTQuNlY2OC4zQzE0My41LDY2LjgsMTQyLjMsNjUuMiwxNDAuOCw2NC40eiIvPgo8L3N2Zz4K"
/>
</div>
<span class="wg-cap-btn__text">{{
$t('loginPage.captchaDefault')
}}</span>
</div>
</div>
<div v-if="captchaStatus === 'check'" class="wg-cap-state__check">
<!-- check -->
<div class="wg-cap-state__inner">
<div class="wg-cap-btn__ico">
<img
src="data:image/svg+xml;base64,PHN2ZyB0PSIxNjI3MDU1NTg2NTk0IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEyMTEiIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj48cGF0aCBkPSJNMTIwLjI1OTQ1NiA1MTIuMDAxMDIzbS0xMTcuOTIzNzYgMGExMTUuMjM4IDExNS4yMzggMCAxIDAgMjM1Ljg0NzUxOSAwIDExNS4yMzggMTE1LjIzOCAwIDEgMC0yMzUuODQ3NTE5IDBaIiBwLWlkPSIxMjEyIiBmaWxsPSIjZmZhMDAwIj48L3BhdGg+PHBhdGggZD0iTTUxMS45OTk0ODggNTEyLjAwMTAyM20tMTE3LjkyMTcxMyAwYTExNS4yMzYgMTE1LjIzNiAwIDEgMCAyMzUuODQzNDI2IDAgMTE1LjIzNiAxMTUuMjM2IDAgMSAwLTIzNS44NDM0MjYgMFoiIHAtaWQ9IjEyMTMiIGZpbGw9IiNmZmEwMDAiPjwvcGF0aD48cGF0aCBkPSJNOTAzLjczOTUyMSA1MTIuMDAxMDIzbS0xMTcuOTIzNzYgMGExMTUuMjM4IDExNS4yMzggMCAxIDAgMjM1Ljg0NzUxOSAwIDExNS4yMzggMTE1LjIzOCAwIDEgMC0yMzUuODQ3NTE5IDBaIiBwLWlkPSIxMjE0IiBmaWxsPSIjZmZhMDAwIj48L3BhdGg+PC9zdmc+"
alt=""
/>
</div>
<span class="wg-cap-btn__text">{{
$t('loginPage.captchaCheck')
}}</span>
</div>
</div>
<div
v-if="captchaStatus === 'error'"
class="wg-cap-state__error"
@click="handleBtnEvent"
>
<!-- error -->
<div class="wg-cap-state__inner">
<div class="wg-cap-btn__ico">
<img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyMDAgMjAwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGw6I0VENDYzMDt9Cjwvc3R5bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xODQsMjYuNkwxMDIuNCwyLjFoLTQuOUwxNiwyNi42Yy0zLjMsMS42LTUuNyw0LjktNS43LDguMnY0OS44YzAsNTIuMiwzMi42LDk0LjcsODcuMywxMTIuNgoJYzAuOCwwLDEuNiwwLjgsMi40LDAuOHMxLjYsMCwyLjQtMC44YzU0LjctMTgsODcuMy01OS42LDg3LjMtMTEyLjZWMzQuN0MxODkuOCwzMS41LDE4Ny4zLDI4LjIsMTg0LDI2LjZ6IE0xMzQuNSwxMjMuMQoJYzMuMSwzLjEsMy4xLDguMiwwLDExLjNjLTEuNiwxLjYtMy42LDIuMy01LjcsMi4zcy00LjEtMC44LTUuNy0yLjNMMTAwLDExMS4zbC0yMy4xLDIzLjFjLTEuNiwxLjYtMy42LDIuMy01LjcsMi4zCgljLTIsMC00LjEtMC44LTUuNy0yLjNjLTMuMS0zLjEtMy4xLTguMiwwLTExLjNMODguNywxMDBMNjUuNSw3Ni45Yy0zLjEtMy4xLTMuMS04LjIsMC0xMS4zYzMuMS0zLjEsOC4yLTMuMSwxMS4zLDBMMTAwLDg4LjcKCWwyMy4xLTIzLjFjMy4xLTMuMSw4LjItMy4xLDExLjMsMGMzLjEsMy4xLDMuMSw4LjIsMCwxMS4zTDExMS4zLDEwMEwxMzQuNSwxMjMuMXoiLz4KPC9zdmc+Cg=="
alt="失败"
/>
</div>
<span
>{{ $t('loginPage.captchaError') }}
<em>{{ $t('loginPage.captchaRetry') }}</em></span
>
</div>
</div>
<div
v-if="captchaStatus === 'over'"
class="wg-cap-state__over"
@click="handleBtnEvent"
>
<!-- too many times error -->
<div class="wg-cap-state__inner">
<div class="wg-cap-btn__ico">
<img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyMDAgMjAwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGw6I0VENDYzMDt9Cjwvc3R5bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xODQsMjYuNkwxMDIuNCwyLjFoLTQuOUwxNiwyNi42Yy0zLjMsMS42LTUuNyw0LjktNS43LDguMnY0OS44YzAsNTIuMiwzMi42LDk0LjcsODcuMywxMTIuNgoJYzAuOCwwLDEuNiwwLjgsMi40LDAuOHMxLjYsMCwyLjQtMC44YzU0LjctMTgsODcuMy01OS42LDg3LjMtMTEyLjZWMzQuN0MxODkuOCwzMS41LDE4Ny4zLDI4LjIsMTg0LDI2LjZ6IE0xMzQuNSwxMjMuMQoJYzMuMSwzLjEsMy4xLDguMiwwLDExLjNjLTEuNiwxLjYtMy42LDIuMy01LjcsMi4zcy00LjEtMC44LTUuNy0yLjNMMTAwLDExMS4zbC0yMy4xLDIzLjFjLTEuNiwxLjYtMy42LDIuMy01LjcsMi4zCgljLTIsMC00LjEtMC44LTUuNy0yLjNjLTMuMS0zLjEtMy4xLTguMiwwLTExLjNMODguNywxMDBMNjUuNSw3Ni45Yy0zLjEtMy4xLTMuMS04LjIsMC0xMS4zYzMuMS0zLjEsOC4yLTMuMSwxMS4zLDBMMTAwLDg4LjcKCWwyMy4xLTIzLjFjMy4xLTMuMSw4LjItMy4xLDExLjMsMGMzLjEsMy4xLDMuMSw4LjIsMCwxMS4zTDExMS4zLDEwMEwxMzQuNSwxMjMuMXoiLz4KPC9zdmc+Cg=="
alt="失败"
/>
</div>
<span
>{{ $t('loginPage.captchaOver') }}
<em>{{ $t('loginPage.captchaRetry') }}</em></span
>
</div>
</div>
<div v-if="captchaStatus === 'success'" class="wg-cap-state__success">
<!-- success -->
<div class="wg-cap-state__inner">
<div class="wg-cap-btn__ico">
<img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAyMDAgMjAwOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+Cgkuc3Qwe2ZpbGw6IzVFQUEyRjt9Cjwvc3R5bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0xODMuMywyNy4yTDEwMi40LDIuOWgtNC45TDE2LjcsMjcuMkMxMy40LDI4LjgsMTEsMzIsMTEsMzUuM3Y0OS40YzAsNTEuOCwzMi40LDkzLjksODYuNiwxMTEuNwoJYzAuOCwwLDEuNiwwLjgsMi40LDAuOGMwLjgsMCwxLjYsMCwyLjQtMC44YzU0LjItMTcuOCw4Ni42LTU5LjEsODYuNi0xMTEuN1YzNS4zQzE4OSwzMiwxODYuNiwyOC44LDE4My4zLDI3LjJ6IE0xNDYuMSw4MS40CglsLTQ4LjUsNDguNWMtMS42LDEuNi0zLjIsMi40LTUuNywyLjRjLTIuNCwwLTQtMC44LTUuNy0yLjRMNjIsMTA1LjdjLTMuMi0zLjItMy4yLTguMSwwLTExLjNjMy4yLTMuMiw4LjEtMy4yLDExLjMsMGwxOC42LDE4LjYKCWw0Mi45LTQyLjljMy4yLTMuMiw4LjEtMy4yLDExLjMsMEMxNDkuNCw3My4zLDE0OS40LDc4LjIsMTQ2LjEsODEuNEwxNDYuMSw4MS40eiIvPgo8L3N2Zz4K"
alt="成功"
/>
</div>
<span>{{ $t('loginPage.captchaSuccess') }}</span>
</div>
</div>
</template>
</el-popover>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import GoCaptcha from './GoCaptcha.vue'
const props = defineProps({
modelValue: {
type: String,
default: 'default',
validator: (modelValue: string) =>
['default', 'check', 'error', 'over', 'success'].indexOf(modelValue) > -1,
},
width: {
type: String,
default: '',
},
height: {
type: String,
default: '',
},
maxDot: {
type: Number,
default: 5,
},
imageBase64: {
type: String,
default: '',
},
thumbBase64: {
type: String,
default: '',
},
})
const popoverRef = ref()
const popoverVisible = ref(false)
const captchaStatus = ref('default')
const style = computed(() => {
return `width:${props.width}; height:${props.height};`
})
const activeClass = computed(() => {
return `wg-cap-active__${captchaStatus.value}`
})
watch(popoverVisible, (val: boolean) => {
if (val) {
captchaStatus.value = 'check'
emit('refresh')
} else if (captchaStatus.value === 'check') {
captchaStatus.value = props.modelValue
}
if (!val) {
popoverRef.value.hide()
}
})
watch(
() => props.modelValue,
(val: (typeof props)['modelValue']) => {
if (captchaStatus.value !== 'check') {
captchaStatus.value = val
}
if (val === 'over' || val === 'success') {
setTimeout(() => {
popoverVisible.value = false
}, 0)
}
}
)
watch(captchaStatus, (val: string) => {
if (val !== 'check' && props.modelValue !== val) {
emit('input', val)
}
})
const emit = defineEmits(['input', 'refresh', 'confirm'])
function handleBtnEvent() {
if (!['check', 'success'].includes(captchaStatus.value)) {
setTimeout(() => {
popoverVisible.value = true
}, 0)
}
}
function handleRefreshEvent() {
captchaStatus.value = 'check'
emit('refresh')
}
function handleConfirmEvent(data: any) {
emit('confirm', data)
}
function handleCloseEvent() {
popoverVisible.value = false
}
</script>
<style>
.wg-cap-btn {
width: 100%;
height: 48px;
}
.wg-cap-btn .wg-cap-btn__inner {
width: 100%;
height: 44px;
position: relative;
letter-spacing: 1px;
}
.wg-cap-btn .wg-cap-state__default,
.wg-cap-btn .wg-cap-state__check,
.wg-cap-btn .wg-cap-state__error,
.wg-cap-btn .wg-cap-state__success,
.wg-cap-btn .wg-cap-state__over {
position: absolute;
width: 100%;
height: 44px;
font-size: 13px;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
-webkit-appearance: none;
box-sizing: border-box;
outline: none;
margin: 0;
transition: 0.1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
justify-content: center;
justify-items: center;
visibility: hidden;
}
.wg-cap-btn .wg-cap-state__default {
color: #3e7cff;
border: 1px solid #50a1ff;
background: #ecf5ff;
box-shadow: 0 0 20px rgba(62, 124, 255, 0.1);
-webkit-box-shadow: 0 0 20px rgba(62, 124, 255, 0.1);
-moz-box-shadow: 0 0 20px rgba(62, 124, 255, 0.1);
}
.wg-cap-btn .wg-cap-state__check {
cursor: default;
color: #ffa000;
background: #fdf6ec;
border: 1px solid #ffbe09;
pointer-events: none;
}
.wg-cap-btn .wg-cap-state__error {
color: #ed4630;
background: #fef0f0;
border: 1px solid #ff5a34;
}
.wg-cap-btn .wg-cap-state__over {
color: #ed4630;
background: #fef0f0;
border: 1px solid #ff5a34;
}
.wg-cap-btn .wg-cap-state__success {
color: #5eaa2f;
background: #f0f9eb;
border: 1px solid #8bc640;
pointer-events: none;
}
.wg-cap-btn .wg-cap-active__default .wg-cap-state__default,
.wg-cap-btn .wg-cap-active__error .wg-cap-state__error,
.wg-cap-btn .wg-cap-active__over .wg-cap-state__over,
.wg-cap-btn .wg-cap-active__success .wg-cap-state__success,
.wg-cap-btn .wg-cap-active__check .wg-cap-state__check {
visibility: visible;
}
.wg-cap-btn .wg-cap-state__inner {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
justify-content: center;
justify-items: center;
}
.wg-cap-btn .wg-cap-state__inner em {
padding-left: 5px;
color: #3e7cff;
font-style: normal;
}
.wg-cap-btn .wg-cap-btn__inner .wg-cap-btn__ico {
position: relative;
width: 24px;
height: 24px;
margin-right: 12px;
font-size: 14px;
display: inline-block;
float: left;
flex: 0;
}
.wg-cap-btn .wg-cap-btn__inner .wg-cap-btn__ico img {
width: 24px;
height: 24px;
float: left;
position: relative;
z-index: 10;
}
@keyframes ripple {
0% {
opacity: 0;
}
5% {
opacity: 0.05;
}
20% {
opacity: 0.35;
}
65% {
opacity: 0.01;
}
100% {
transform: scaleX(2) scaleY(2);
opacity: 0;
}
}
@-webkit-keyframes ripple {
0% {
opacity: 0;
}
5% {
opacity: 0.05;
}
20% {
opacity: 0.35;
}
65% {
opacity: 0.01;
}
100% {
transform: scaleX(2) scaleY(2);
opacity: 0;
}
}
.wg-cap-btn .wg-cap-btn__inner .wg-cap-btn__verify::after {
background: #409eff;
-webkit-border-radius: 50px;
-moz-border-radius: 50px;
border-radius: 50px;
content: '';
display: block;
width: 24px;
height: 24px;
opacity: 0;
position: absolute;
top: 0;
left: 0;
z-index: 9;
animation: ripple 1.3s infinite;
-moz-animation: ripple 1.3s infinite;
-webkit-animation: ripple 1.3s infinite;
animation-delay: 2s;
-moz-animation-delay: 2s;
-webkit-animation-delay: 2s;
}
.wg-cap-tip {
padding: 50px 20px 100px;
font-size: 13px;
color: #76839b;
text-align: center;
line-height: 180%;
width: 100%;
max-width: 680px;
}
</style>

View File

@ -52,6 +52,19 @@
</span>
</el-form-item>
<div v-if="captchaEnabled && captchaShow">
<GoCaptchaBtn
v-model="captchaStatus"
class="go-captcha-btn"
width="100%"
height="44px"
:image-base64="captchaBase64"
:thumb-base64="captchaThumbBase64"
@confirm="handleConfirm"
@refresh="handleRequestCaptCode"
/>
</div>
<el-button
:loading="loading"
type="primary"
@ -84,8 +97,17 @@ import { validUsername, validPassword } from '@/utils/validate'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import type { ElForm } from 'element-plus'
import { ElMessage } from 'element-plus'
import { ref, watch, nextTick, reactive } from 'vue'
import { MediaLoginUrl } from '@/api/user'
import {
MediaLoginUrl,
GetCaptcha,
CheckCaptcha,
GetCaptchaConfig,
} from '@/api/user'
import GoCaptchaBtn from './components/GoCaptchaBtn.vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n({ useScope: 'global' })
const version = import.meta.env.VITE_APP_VERSION
const store = useStore()
const router = useRouter()
@ -94,6 +116,7 @@ const loginForm = ref({
account: import.meta.env.PROD === true ? '' : 'admin',
password: import.meta.env.PROD === true ? '' : 'admin!@#',
phrase: '',
captchaKey: '',
})
const loginRules: InstanceType<typeof ElForm>['rules'] = {
account: [
@ -172,6 +195,64 @@ const mediaMap = reactive<Record<string, any>>({
},
})
getMediaLoginUrl()
getCaptchaConfig()
const captchaEnabled = ref(false)
const captchaShow = ref(false)
const captchaBase64 = ref('')
const captchaThumbBase64 = ref('')
const captchaKey = ref('')
const captchaStatus = ref('default')
const captchaAutoRefreshCount = ref(0)
function getCaptchaConfig() {
new GetCaptchaConfig().request().then((response) => {
captchaEnabled.value = response.data.enabled
})
}
function handleRequestCaptCode() {
captchaBase64.value = ''
captchaThumbBase64.value = ''
captchaKey.value = ''
new GetCaptcha({ language: locale.value }).request().then((response) => {
captchaBase64.value = response.data.base64
captchaThumbBase64.value = response.data.thumbBase64
captchaKey.value = response.data.key
loginForm.value.captchaKey = response.data.key
})
}
function handleConfirm(dots: { x: number; y: number; index: number }[]) {
if (dots.length < 1) {
ElMessage.warning('please check the captcha')
return
}
let dotArr = []
for (const dot of dots) {
dotArr.push(dot.x, dot.y)
}
new CheckCaptcha({ dots: dotArr, key: captchaKey.value })
.request()
.then((response) => {
ElMessage.success(`captcha passed`)
captchaStatus.value = 'success'
captchaAutoRefreshCount.value = 0
})
.catch(() => {
if (captchaAutoRefreshCount.value > 5) {
captchaAutoRefreshCount.value = 0
captchaStatus.value = 'over'
return
}
handleRequestCaptCode()
captchaAutoRefreshCount.value += 1
captchaStatus.value = 'error'
})
}
const password = ref<HTMLInputElement>()
function showPwd() {
@ -214,6 +295,11 @@ function handleLogin() {
loading.value = false
})
.catch(() => {
if (captchaEnabled.value) {
captchaShow.value = true
captchaStatus.value = 'default'
loginForm.value.captchaKey = ''
}
loading.value = false
})
return Promise.resolve(true)