From fa64fb1e08fe0e68b73093ee4f597f28995f1e1a Mon Sep 17 00:00:00 2001 From: ZhangYang Date: Fri, 10 Apr 2026 10:07:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=9B=BE=E5=BD=A2=E9=AA=8C=E8=AF=81=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加登录图形验证码生成和验证接口 - 实现登录失败次数统计和验证码触发机制 - 登录成功后清除失败记录 - 修复 Code Review 相关问题 --- config/config.go | 3 + controllers/auth_on_corp_manager.go | 41 +++++++++++- controllers/corp_manager.go | 5 ++ go.mod | 3 + go.sum | 42 ++++++++++++ models/adapter.go | 5 ++ models/corp_manager.go | 3 + models/error.go | 1 + models/init.go | 1 + routers/commentsRouter.go | 9 +++ signing.go | 10 ++- signing/adapter/user.go | 20 ++++++ signing/app/user.go | 41 ++++++++++++ signing/app/user_dto.go | 3 + signing/domain/captchaservice/service.go | 12 ++++ signing/domain/config.go | 14 ++++ signing/domain/error.go | 2 + signing/domain/login.go | 6 ++ signing/domain/loginservice/service.go | 22 +++++++ signing/infrastructure/captchaimpl/config.go | 19 ++++++ signing/infrastructure/captchaimpl/impl.go | 65 +++++++++++++++++++ signing/infrastructure/captchaimpl/redisdb.go | 52 +++++++++++++++ .../repositoryimpl/corp_signing_cache.go | 8 +++ 23 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 signing/domain/captchaservice/service.go create mode 100644 signing/infrastructure/captchaimpl/config.go create mode 100644 signing/infrastructure/captchaimpl/impl.go create mode 100644 signing/infrastructure/captchaimpl/redisdb.go diff --git a/config/config.go b/config/config.go index 6999ceae..2d9a2b44 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( "github.com/opensourceways/app-cla-server/signing/domain" "github.com/opensourceways/app-cla-server/signing/domain/dp" "github.com/opensourceways/app-cla-server/signing/infrastructure/accesstokenimpl" + "github.com/opensourceways/app-cla-server/signing/infrastructure/captchaimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/localclaimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/loginimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/passwordimpl" @@ -48,6 +49,7 @@ type redisdbConfig struct { DB redisdb.Config `json:"db"` Login loginimpl.Config `json:"login"` AccessToken accesstokenimpl.Config `json:"access_token"` + Captcha captchaimpl.Config `json:"captcha"` } type Config struct { @@ -77,6 +79,7 @@ func (cfg *Config) ConfigItems() []interface{} { &cfg.Redisdb.DB, &cfg.Redisdb.Login, &cfg.Redisdb.AccessToken, + &cfg.Redisdb.Captcha, &cfg.Password, &cfg.LocalCLA, &cfg.Symmetric, diff --git a/controllers/auth_on_corp_manager.go b/controllers/auth_on_corp_manager.go index 6a4e0d0d..adfefead 100644 --- a/controllers/auth_on_corp_manager.go +++ b/controllers/auth_on_corp_manager.go @@ -8,7 +8,8 @@ import ( type corpAuthFailure struct { errMsg - RetryNum int `json:"retry_num"` + RetryNum int `json:"retry_num"` + NeedCaptcha bool `json:"need_captcha,omitempty"` } // @Title Logout @@ -52,7 +53,17 @@ func (ctl *CorporationManagerController) Login() { if merr != nil { if merr.IsErrorOf(models.ErrWrongIDOrPassword) { body := corpAuthFailure{ - RetryNum: v.RetryNum, + RetryNum: v.RetryNum, + NeedCaptcha: v.NeedCaptcha, + } + body.ErrCode = merr.ErrCode() + body.ErrMsg = merr.Error() + + ctl.sendResponse(action, body, 400) + + } else if merr.IsErrorOf(models.ErrCaptchaInvalid) { + body := corpAuthFailure{ + NeedCaptcha: true, } body.ErrCode = merr.ErrCode() body.ErrMsg = merr.Error() @@ -83,6 +94,32 @@ func (ctl *CorporationManagerController) Login() { ctl.addOperationLog(v.UserId+" / "+v.Role, action, 0) } +// @Title GetCaptcha +// @Description get a graphic captcha image for login brute-force protection +// @Tags CorpManager +// @Produce json +// @Success 200 +// @router /captcha [get] +func (ctl *CorporationManagerController) GetCaptcha() { + action := "get login captcha" + + id, image, merr := models.GetLoginCaptcha() + if merr != nil { + ctl.sendModelErrorAsResp(merr, action) + return + } + + ctl.sendSuccessResp(action, loginCaptchaResp{ + CaptchaId: id, + CaptchaImage: image, + }) +} + +type loginCaptchaResp struct { + CaptchaId string `json:"captcha_id"` + CaptchaImage string `json:"captcha_image"` +} + func (ctl *CorporationManagerController) genToken(linkID string, info *models.CorpManagerLoginInfo) error { permission := "" switch info.Role { diff --git a/controllers/corp_manager.go b/controllers/corp_manager.go index 410dffad..237bb111 100644 --- a/controllers/corp_manager.go +++ b/controllers/corp_manager.go @@ -24,6 +24,11 @@ func (ctl *CorporationManagerController) Prepare() { } if ctl.isGetRequest() { + if strings.HasSuffix(ctl.routerPattern(), "/captcha") { + // get login captcha — no authentication required + return + } + // get basic info ctl.apiPrepareWithAC( &accessController{Payload: &acForCorpManagerPayload{}}, diff --git a/go.mod b/go.mod index bbbf2057..95259620 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,13 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/kr/text v0.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mojocn/base64Captcha v1.3.8 // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/prometheus/client_golang v1.19.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect @@ -33,6 +35,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + golang.org/x/image v0.23.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index bb5270d0..70f27ae8 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +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/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -36,6 +38,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg= +github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -80,20 +84,39 @@ go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -102,21 +125,40 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w 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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= diff --git a/models/adapter.go b/models/adapter.go index 77b61a17..8d0aae15 100644 --- a/models/adapter.go +++ b/models/adapter.go @@ -230,6 +230,11 @@ func ResetPassword(linkId string, opt *PasswordRetrieval, key string) IModelErro return userAdapterInstance.ResetPassword(linkId, key, opt.Password) } +// GetLoginCaptcha returns a new graphic captcha ID and base64-encoded image. +func GetLoginCaptcha() (string, string, IModelError) { + return userAdapterInstance.GetCaptcha() +} + // migration func MigrateCommunityData(userId string, opt *CommunityMigrationOpt) IModelError { return migrationAdapterInstance.MigrateCommunityData(userId, opt) diff --git a/models/corp_manager.go b/models/corp_manager.go index 745bca6c..d12c1980 100644 --- a/models/corp_manager.go +++ b/models/corp_manager.go @@ -16,6 +16,8 @@ type CorporationManagerLoginInfo struct { LinkID string `json:"link_id"` Password []byte `json:"password"` PrivacyConsented bool `json:"privacy_consented"` + CaptchaId string `json:"captcha_id"` + CaptchaAnswer string `json:"captcha_answer"` } func (info *CorporationManagerLoginInfo) Validate() IModelError { @@ -49,6 +51,7 @@ type CorpManagerLoginInfo struct { PrivacyVersion string InitialPWChanged bool RetryNum int + NeedCaptcha bool } type CorporationManagerCreateOption struct { diff --git a/models/error.go b/models/error.go index 0a7846d9..6b68e603 100644 --- a/models/error.go +++ b/models/error.go @@ -53,6 +53,7 @@ const ( ErrTooManyRequest ModelErrCode = "too_many_request" ErrUserLoginFrozen ModelErrCode = "user_login_frozen" ErrUserNotExists ModelErrCode = "user_not_exists" + ErrCaptchaInvalid ModelErrCode = "captcha_invalid" ErrCLAIsUsed ModelErrCode = "cla_is_used" ErrLinkIsUsed ModelErrCode = "link_is_used" ErrNoPermission ModelErrCode = "no_permission" diff --git a/models/init.go b/models/init.go index 8e725f19..fc709512 100644 --- a/models/init.go +++ b/models/init.go @@ -77,6 +77,7 @@ type userAdapter interface { ChangePassword(string, *CorporationManagerChangePassword) IModelError ResetPassword(linkId string, email string, password []byte) IModelError GenKeyForPasswordRetrieval(linkId string, email string) (string, IModelError) + GetCaptcha() (id string, imageBase64 string, err IModelError) } func RegisterUserAdapter(a userAdapter) { diff --git a/routers/commentsRouter.go b/routers/commentsRouter.go index abfaf8c0..3530d533 100644 --- a/routers/commentsRouter.go +++ b/routers/commentsRouter.go @@ -133,6 +133,15 @@ func init() { Filters: nil, Params: nil}) + beego.GlobalControllerRouter["github.com/opensourceways/app-cla-server/controllers:CorporationManagerController"] = append(beego.GlobalControllerRouter["github.com/opensourceways/app-cla-server/controllers:CorporationManagerController"], + beego.ControllerComments{ + Method: "GetCaptcha", + Router: `/captcha`, + AllowHTTPMethods: []string{"get"}, + MethodParams: param.Make(), + Filters: nil, + Params: nil}) + beego.GlobalControllerRouter["github.com/opensourceways/app-cla-server/controllers:CorporationPDFController"] = append(beego.GlobalControllerRouter["github.com/opensourceways/app-cla-server/controllers:CorporationPDFController"], beego.ControllerComments{ Method: "Review", diff --git a/signing.go b/signing.go index af5dafc9..07030ef9 100644 --- a/signing.go +++ b/signing.go @@ -16,6 +16,7 @@ import ( "github.com/opensourceways/app-cla-server/signing/domain/userservice" "github.com/opensourceways/app-cla-server/signing/domain/vcservice" "github.com/opensourceways/app-cla-server/signing/infrastructure/accesstokenimpl" + "github.com/opensourceways/app-cla-server/signing/infrastructure/captchaimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/encryptionimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/limiterimpl" "github.com/opensourceways/app-cla-server/signing/infrastructure/localclaimpl" @@ -131,7 +132,14 @@ func initSigning(cfg *config.Config) error { adapter.NewUserAdapter( app.NewUserService( userService, loginService, repo, symmetric, ur, - interval, vcService, privacyVersion, + interval, vcService, + captchaimpl.NewCaptchaImpl( + redisdb.DAO(), + &cfg.Redisdb.Captcha, + cfg.Domain.Config.IsTestEnvironment, + cfg.Domain.Config.TestCaptchaAnswer, + ), + privacyVersion, ), ), ) diff --git a/signing/adapter/user.go b/signing/adapter/user.go index df9b2abb..5cd5bb8c 100644 --- a/signing/adapter/user.go +++ b/signing/adapter/user.go @@ -118,6 +118,7 @@ func (adapter *userAdatper) Login(opt *models.CorporationManagerLoginInfo) ( v, err := adapter.s.Login(&cmd) if err != nil { r.RetryNum = v.RetryNum + r.NeedCaptcha = v.NeedCaptcha code, ok := err.(errorCode) // unify the error message @@ -135,6 +136,13 @@ func (adapter *userAdatper) Login(opt *models.CorporationManagerLoginInfo) ( ) } + if ok && code.ErrorCode() == domain.ErrorCodeCaptchaInvalid { + return r, models.NewModelError( + models.ErrCaptchaInvalid, + errors.New("captcha invalid"), + ) + } + return r, toModelError(err) } @@ -148,6 +156,16 @@ func (adapter *userAdatper) Login(opt *models.CorporationManagerLoginInfo) ( return r, nil } +// GetCaptcha +func (adapter *userAdatper) GetCaptcha() (string, string, models.IModelError) { + id, image, err := adapter.s.GetCaptcha() + if err != nil { + return "", "", toModelError(err) + } + + return id, image, nil +} + // GetUserInfo func (adapter *userAdatper) GetUserInfo(userId string) ( models.CorpManagerUserInfo, models.IModelError, @@ -171,6 +189,8 @@ func (adapter *userAdatper) cmdToLogin(opt *models.CorporationManagerLoginInfo) ) { cmd.LinkId = opt.LinkID cmd.PrivacyConsented = opt.PrivacyConsented + cmd.CaptchaId = opt.CaptchaId + cmd.CaptchaAnswer = opt.CaptchaAnswer if cmd.Password, err = dp.NewPassword(opt.Password); err != nil { return diff --git a/signing/app/user.go b/signing/app/user.go index 0ad6a875..b62eb442 100644 --- a/signing/app/user.go +++ b/signing/app/user.go @@ -6,7 +6,10 @@ import ( "errors" "time" + "github.com/beego/beego/v2/core/logs" + "github.com/opensourceways/app-cla-server/signing/domain" + "github.com/opensourceways/app-cla-server/signing/domain/captchaservice" "github.com/opensourceways/app-cla-server/signing/domain/loginservice" "github.com/opensourceways/app-cla-server/signing/domain/repository" "github.com/opensourceways/app-cla-server/signing/domain/symmetricencryption" @@ -22,6 +25,7 @@ func NewUserService( userRepo repository.User, interval time.Duration, vcService vcservice.VCService, + captchaSvc captchaservice.CaptchaService, privacyVersion string, ) UserService { return &userService{ @@ -32,6 +36,7 @@ func NewUserService( userRepo: userRepo, interval: interval, vcService: verificationCodeService{vcService}, + captchaService: captchaSvc, privacyVersion: privacyVersion, } } @@ -39,6 +44,7 @@ func NewUserService( type UserService interface { Get(userId string) (dto UserBasicInfoDTO, err error) Login(cmd *CmdToLogin) (dto UserLoginDTO, err error) + GetCaptcha() (id string, imageBase64 string, err error) ResetPassword(cmd *CmdToResetPassword) error ChangePassword(cmd *CmdToChangePassword) error GenKeyForPasswordRetrieval(*CmdToGenKeyForPasswordRetrieval) (string, error) @@ -52,6 +58,7 @@ type userService struct { userRepo repository.User interval time.Duration vcService verificationCodeService + captchaService captchaservice.CaptchaService privacyVersion string } @@ -132,9 +139,37 @@ func (s *userService) ResetPassword(cmd *CmdToResetPassword) error { return s.us.ResetPassword(cmd.LinkId, e, cmd.NewOne) } +func (s *userService) GetCaptcha() (id string, imageBase64 string, err error) { + return s.captchaService.Generate() +} + func (s *userService) Login(cmd *CmdToLogin) (dto UserLoginDTO, err error) { defer cmd.clear() + // Determine the login identifier (account name or email address). + var lid string + if cmd.Account != nil { + lid = cmd.Account.Account() + } else { + lid = cmd.Email.EmailAddr() + } + + // Check whether captcha verification is required before attempting login. + // Note: If user provides captcha_id and captcha_answer, verify them first. + if cmd.CaptchaId != "" && cmd.CaptchaAnswer != "" { + if verifyErr := s.captchaService.Verify(cmd.CaptchaId, cmd.CaptchaAnswer); verifyErr != nil { + dto.NeedCaptcha = true + err = domain.NewDomainError(domain.ErrorCodeCaptchaInvalid) + return + } + } else if needed, checkErr := s.ls.NeedCaptcha(lid); checkErr != nil { + logs.Warn("failed to check captcha requirement, err: %s", checkErr.Error()) + } else if needed { + dto.NeedCaptcha = true + err = domain.NewDomainError(domain.ErrorCodeCaptchaInvalid) + return + } + var u domain.User var l domain.Login @@ -146,11 +181,17 @@ func (s *userService) Login(cmd *CmdToLogin) (dto UserLoginDTO, err error) { // It should record the retry number whatever if it is success or not. dto.RetryNum = l.RetryNum() + dto.NeedCaptcha = l.NeedCaptcha() if err != nil { return } + // 登录成功后清除失败记录 + if err1 := s.ls.ClearLoginFailure(lid); err1 != nil { + logs.Warn("clear login failure failed, err: %s", err1.Error()) + } + if err = s.checkPrivacyConsent(cmd.PrivacyConsented, &u); err != nil { return } diff --git a/signing/app/user_dto.go b/signing/app/user_dto.go index e4545393..9c7eba2b 100644 --- a/signing/app/user_dto.go +++ b/signing/app/user_dto.go @@ -12,6 +12,8 @@ type CmdToLogin struct { Account dp.Account Password dp.Password PrivacyConsented bool + CaptchaId string + CaptchaAnswer string } func (cmd *CmdToLogin) clear() { @@ -34,6 +36,7 @@ type UserLoginDTO struct { PrivacyVersion string InitialPWChanged bool RetryNum int + NeedCaptcha bool } // CmdToChangePassword diff --git a/signing/domain/captchaservice/service.go b/signing/domain/captchaservice/service.go new file mode 100644 index 00000000..d70dee39 --- /dev/null +++ b/signing/domain/captchaservice/service.go @@ -0,0 +1,12 @@ +package captchaservice + +// CaptchaService handles graphic captcha generation and verification +// to prevent brute-force login attacks. +type CaptchaService interface { + // Generate creates a new captcha and returns its ID and base64-encoded image. + Generate() (id string, imageBase64 string, err error) + + // Verify checks whether the provided answer matches the captcha for the given ID. + // Returns an error if the captcha is invalid or expired. + Verify(id string, answer string) error +} diff --git a/signing/domain/config.go b/signing/domain/config.go index 28933c18..977c6318 100644 --- a/signing/domain/config.go +++ b/signing/domain/config.go @@ -32,6 +32,12 @@ type Config struct { MaxNumOfFailedLogin int `json:"max_num_of_failed_login"` + // NeedCaptchaThreshold: number of consecutive failures before captcha is required. + NeedCaptchaThreshold int `json:"need_captcha_threshold"` + + // TestCaptchaAnswer is the fixed captcha answer used in test environments. + TestCaptchaAnswer string `json:"test_captcha_answer"` + // interval of creating verification code. seconds. IntervalOfCreatingVC int `json:"interval_of_creating_vc"` @@ -73,6 +79,14 @@ func (cfg *Config) SetDefault() { cfg.MaxNumOfFailedLogin = 5 } + if cfg.NeedCaptchaThreshold <= 0 { + cfg.NeedCaptchaThreshold = 1 + } + + if cfg.IsTestEnvironment && cfg.TestCaptchaAnswer == "" { + cfg.TestCaptchaAnswer = "123456" + } + if cfg.IntervalOfCreatingVC <= 0 { cfg.IntervalOfCreatingVC = 60 } diff --git a/signing/domain/error.go b/signing/domain/error.go index d47e5c8d..416e2262 100644 --- a/signing/domain/error.go +++ b/signing/domain/error.go @@ -52,6 +52,8 @@ const ( ErrorCodeAccessTokenInvalid = "access_token_invalid" + ErrorCodeCaptchaInvalid = "captcha_invalid" + ErrorCodeCLAExists = "cla_exists" ErrorCodeCLANotExists = "cla_not_exists" ErrorCodeCLACanNotRemove = "cla_can_not_remove" diff --git a/signing/domain/login.go b/signing/domain/login.go index 91cf9815..579bd730 100644 --- a/signing/domain/login.go +++ b/signing/domain/login.go @@ -33,3 +33,9 @@ func (l *Login) RetryNum() int { func (l *Login) HasFailure() bool { return l.FailedNum > 0 } + +// NeedCaptcha returns true when the login has enough failures to require +// graphic captcha verification on the next attempt. +func (l *Login) NeedCaptcha() bool { + return !l.Frozen && l.FailedNum >= config.NeedCaptchaThreshold +} diff --git a/signing/domain/loginservice/service.go b/signing/domain/loginservice/service.go index 18ad73e2..a29d2bb4 100644 --- a/signing/domain/loginservice/service.go +++ b/signing/domain/loginservice/service.go @@ -33,6 +33,11 @@ func NewLoginService( type LoginService interface { LoginByAccount(linkId string, a dp.Account, p dp.Password) (domain.User, domain.Login, error) LoginByEmail(linkId string, e dp.EmailAddr, p dp.Password) (domain.User, domain.Login, error) + // NeedCaptcha returns true when the given login ID has accumulated enough + // failures to require graphic captcha on the next attempt. + NeedCaptcha(id string) (bool, error) + // ClearLoginFailure clears the login failure record after successful captcha verification. + ClearLoginFailure(id string) error } type loginService struct { @@ -124,3 +129,20 @@ func (s *loginService) failToLogin(l *domain.Login) error { func (s *loginService) isCorrectPassword(p dp.Password, ciphertext []byte) bool { return s.encrypt.IsSame(p.Password(), ciphertext) } + +func (s *loginService) NeedCaptcha(id string) (bool, error) { + lv, err := s.repo.Find(id) + if err != nil { + if commonRepo.IsErrorResourceNotFound(err) { + return false, nil + } + return false, err + } + + return lv.NeedCaptcha(), nil +} + +// ClearLoginFailure clears the login failure record after successful captcha verification. +func (s *loginService) ClearLoginFailure(id string) error { + return s.repo.Delete(id) +} diff --git a/signing/infrastructure/captchaimpl/config.go b/signing/infrastructure/captchaimpl/config.go new file mode 100644 index 00000000..814d4155 --- /dev/null +++ b/signing/infrastructure/captchaimpl/config.go @@ -0,0 +1,19 @@ +package captchaimpl + +import "time" + +// Config holds captcha storage configuration. +type Config struct { + // Expiry is how long a captcha remains valid, in seconds. + Expiry int64 `json:"expiry"` +} + +func (cfg *Config) SetDefault() { + if cfg.Expiry <= 0 { + cfg.Expiry = 300 // 5 minutes default + } +} + +func (cfg *Config) expiry() time.Duration { + return time.Duration(cfg.Expiry) * time.Second +} diff --git a/signing/infrastructure/captchaimpl/impl.go b/signing/infrastructure/captchaimpl/impl.go new file mode 100644 index 00000000..4ac0f674 --- /dev/null +++ b/signing/infrastructure/captchaimpl/impl.go @@ -0,0 +1,65 @@ +package captchaimpl + +import ( + "github.com/mojocn/base64Captcha" + + "github.com/opensourceways/app-cla-server/signing/domain" +) + +var errCaptchaInvalid = domain.NewDomainError(domain.ErrorCodeCaptchaInvalid) + +// NewCaptchaImpl creates a CaptchaService implementation. +// In test mode, every generated captcha has a fixed answer (testAnswer). +func NewCaptchaImpl(d dao, cfg *Config, isTestEnv bool, testAnswer string) *captchaImpl { + store := &captchaStore{dao: d, expiry: cfg.expiry()} + + driver := base64Captcha.NewDriverDigit( + 80, // height + 240, // width + 6, // digit length + 0.7, // noise level + 80, // background circle count + ) + + return &captchaImpl{ + captcha: base64Captcha.NewCaptcha(driver, store), + store: store, + isTestEnv: isTestEnv, + testAnswer: testAnswer, + } +} + +type captchaImpl struct { + captcha *base64Captcha.Captcha + store *captchaStore + isTestEnv bool + testAnswer string +} + +// Generate creates a new captcha and returns (id, base64Image, error). +func (c *captchaImpl) Generate() (id string, imageBase64 string, err error) { + // if c.isTestEnv { + // id = uuid.New().String() + // if err = c.store.Set(id, c.testAnswer); err != nil { + // return + // } + // imageBase64 = "test_mode" + // return + // } + + id, imageBase64, _, err = c.captcha.Generate() + return +} + +// Verify validates the captcha answer. The captcha is consumed on success. +func (c *captchaImpl) Verify(id string, answer string) error { + if id == "" || answer == "" { + return errCaptchaInvalid + } + + if !c.captcha.Verify(id, answer, true) { + return errCaptchaInvalid + } + + return nil +} diff --git a/signing/infrastructure/captchaimpl/redisdb.go b/signing/infrastructure/captchaimpl/redisdb.go new file mode 100644 index 00000000..5b12e30c --- /dev/null +++ b/signing/infrastructure/captchaimpl/redisdb.go @@ -0,0 +1,52 @@ +package captchaimpl + +import ( + "strings" + "time" +) + +// dao is the Redis DAO interface required by captchaStore. +type dao interface { + SetWithExpiry(key string, val interface{}, expiry time.Duration) error + Get(key string, val interface{}) error + Del(keys ...string) error + IsDocNotExists(err error) bool +} + +const captchaKeyPrefix = "captcha:" + +func captchaKey(id string) string { + return captchaKeyPrefix + id +} + +// captchaStore implements base64Captcha.Store backed by Redis. +type captchaStore struct { + dao dao + expiry time.Duration +} + +// Set stores the captcha answer in Redis with TTL. +func (s *captchaStore) Set(id string, value string) error { + return s.dao.SetWithExpiry(captchaKey(id), value, s.expiry) +} + +// Get retrieves the captcha answer from Redis and optionally deletes it. +func (s *captchaStore) Get(id string, clear bool) string { + var val string + if err := s.dao.Get(captchaKey(id), &val); err != nil { + return "" + } + + if clear { + // delete key immediately + _ = s.dao.Del(captchaKey(id)) + } + + return val +} + +// Verify checks the captcha answer (case-insensitive). +func (s *captchaStore) Verify(id, answer string, clear bool) bool { + stored := s.Get(id, clear) + return stored != "" && strings.EqualFold(stored, answer) +} diff --git a/signing/infrastructure/repositoryimpl/corp_signing_cache.go b/signing/infrastructure/repositoryimpl/corp_signing_cache.go index eeb68b5b..d6faae52 100644 --- a/signing/infrastructure/repositoryimpl/corp_signing_cache.go +++ b/signing/infrastructure/repositoryimpl/corp_signing_cache.go @@ -3,6 +3,7 @@ package repositoryimpl import ( "encoding/json" "fmt" + "log" "time" @@ -27,6 +28,7 @@ type cacheDAO interface { // cachedCorpSigningPage is a serialisable snapshot of CorpSigningSummaryPage. type cachedCorpSigningPage struct { + Total int64 `json:"total"` Data []cachedCorpSigningSummary `json:"data"` } @@ -151,8 +153,10 @@ func (c *cachedCorpSigning) FindPage(linkId string, page, pageSize int, adminAdd // --- cache hit --- var cached cachedCorpSigningPage + if err := c.cache.Get(key, &cached); err == nil { return fromCache(cached), nil + } // --- cache miss: query MongoDB --- @@ -163,6 +167,7 @@ func (c *cachedCorpSigning) FindPage(linkId string, page, pageSize int, adminAdd // populate cache asynchronously so the caller is not blocked go func() { + payload, jsonErr := json.Marshal(toCache(result)) if jsonErr != nil { log.Printf("corp_signing cache marshal error: %v", jsonErr) @@ -180,13 +185,16 @@ func (c *cachedCorpSigning) FindPage(linkId string, page, pageSize int, adminAdd func (c *cachedCorpSigning) invalidateLink(linkId string) { keys, err := c.cache.Keys(linkPattern(linkId)) if err != nil { + log.Printf("corp_signing cache keys error: %v", err) + return } if len(keys) == 0 { return } if err := c.cache.Del(keys...); err != nil { + log.Printf("corp_signing cache invalidate error: %v", err) } }