From 9bef8ddee3931ebc28faf81ce338553ffa711fd1 Mon Sep 17 00:00:00 2001 From: yubo Date: Mon, 2 Nov 2020 12:47:41 +0800 Subject: [PATCH] login with sso,captcha,sms-code (#374) * add logout v2 for sso * support sms-code login * use db instead of memory cache for login code * feature: support reset password by sms code * remove deprecated api/code * feature: support image captcha * use db instead of memory cache for sso.auth.state --- etc/rdb.yml | 2 + go.mod | 1 + go.sum | 5 ++ sql/n9e_rdb.sql | 17 +++++- src/models/auth_state.go | 42 +++++++++++++ src/models/captcha.go | 44 ++++++++++++++ src/modules/rdb/config/yaml.go | 3 +- src/modules/rdb/cron/cleaner.go | 19 ++++++ src/modules/rdb/http/router.go | 5 +- src/modules/rdb/http/router_auth.go | 94 +++++++++++++++-------------- src/modules/rdb/rdb.go | 1 + src/modules/rdb/ssoc/sso.go | 52 +++++++++------- 12 files changed, 210 insertions(+), 75 deletions(-) create mode 100644 src/models/auth_state.go create mode 100644 src/models/captcha.go create mode 100644 src/modules/rdb/cron/cleaner.go diff --git a/etc/rdb.yml b/etc/rdb.yml index afa15202..e2b31aba 100644 --- a/etc/rdb.yml +++ b/etc/rdb.yml @@ -24,6 +24,8 @@ sso: coverAttributes: false stateExpiresIn: 300 +captcha: true + tokens: - rdb-builtin-token diff --git a/go.mod b/go.mod index 0b75b354..01f5099f 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/influxdata/influxdb v1.8.0 github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-sqlite3 v1.14.0 // indirect + github.com/mojocn/base64Captcha v1.3.1 github.com/onsi/ginkgo v1.7.0 // indirect github.com/onsi/gomega v1.4.3 // indirect github.com/open-falcon/rrdlite v0.0.0-20200214140804-bf5829f786ad diff --git a/go.sum b/go.sum index 41a0248a..108049a5 100644 --- a/go.sum +++ b/go.sum @@ -138,6 +138,7 @@ github.com/gogo/protobuf v0.0.0-20171007142547-342cbe0a0415/go.mod h1:r8qH/GZQm5 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +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/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -282,6 +283,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mojocn/base64Captcha v1.3.1 h1:2Wbkt8Oc8qjmNJ5GyOfSo4tgVQPsbKMftqASnq8GlT0= +github.com/mojocn/base64Captcha v1.3.1/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= @@ -427,6 +430,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= diff --git a/sql/n9e_rdb.sql b/sql/n9e_rdb.sql index e36411ce..53aeaf4c 100644 --- a/sql/n9e_rdb.sql +++ b/sql/n9e_rdb.sql @@ -282,8 +282,7 @@ CREATE TABLE `operation_log` PRIMARY KEY (`id`), KEY (`clock`), KEY (`res_cl`, `res_id`) -) ENGINE = InnoDB - DEFAULT CHARSET = utf8; +) ENGINE = InnoDB DEFAULT CHARSET = utf8; CREATE TABLE `login_code` ( @@ -297,5 +296,19 @@ CREATE TABLE `login_code` ) ENGINE = InnoDB DEFAULT CHARSET = utf8; +CREATE TABLE `auth_state` ( + `state` varchar(128) DEFAULT '' NOT NULL, + `typ` varchar(32) DEFAULT '' NOT NULL COMMENT 'response_type', + `redirect` varchar(1024) DEFAULT '' NOT NULL, + `expires_at` bigint DEFAULT '0' NOT NULL, + PRIMARY KEY (`state`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8; +CREATE TABLE `captcha` ( + `captcha_id` varchar(128) NOT NULL, + `answer` varchar(128) DEFAULT '' NOT NULL, + `created_at` bigint DEFAULT '0' NOT NULL, + KEY (`captcha_id`, `answer`), + KEY (`created_at`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8; diff --git a/src/models/auth_state.go b/src/models/auth_state.go new file mode 100644 index 00000000..c3639b9d --- /dev/null +++ b/src/models/auth_state.go @@ -0,0 +1,42 @@ +package models + +import ( + "errors" + "time" +) + +type AuthState struct { + State string `json:"state"` + Typ string `json:"typ"` + Redirect string `json:"redirect"` + ExpiresAt int64 `json:"expiresAt"` +} + +func AuthStateGet(where string, args ...interface{}) (*AuthState, error) { + var obj AuthState + has, err := DB["rdb"].Where(where, args...).Get(&obj) + if err != nil { + return nil, err + } + + if !has { + return nil, errors.New("auth state not found") + } + + return &obj, nil +} + +func (p *AuthState) Save() error { + _, err := DB["rdb"].Insert(p) + return err +} + +func (p *AuthState) Del() error { + _, err := DB["rdb"].Where("state=?", p.State).Delete(new(AuthState)) + return err +} + +func (p AuthState) CleanUp() error { + _, err := DB["rdb"].Exec("delete from auth_state where expires_at < ?", time.Now().Unix()) + return err +} diff --git a/src/models/captcha.go b/src/models/captcha.go new file mode 100644 index 00000000..7ae711e6 --- /dev/null +++ b/src/models/captcha.go @@ -0,0 +1,44 @@ +package models + +import ( + "errors" + "time" +) + +type Captcha struct { + CaptchaId string `json:"captchaId"` + Answer string `json:"-"` + Image string `xorm:"-" json:"image"` + CreatedAt int64 `json:"createdAt"` +} + +func CaptchaGet(where string, args ...interface{}) (*Captcha, error) { + var obj Captcha + has, err := DB["rdb"].Where(where, args...).Get(&obj) + if err != nil { + return nil, err + } + + if !has { + return nil, errors.New("captcha not found") + } + + return &obj, nil +} + +func (p *Captcha) Save() error { + _, err := DB["rdb"].Insert(p) + return err +} + +func (p *Captcha) Del() error { + _, err := DB["rdb"].Where("captcha_id=?", p.CaptchaId).Delete(new(Captcha)) + return err +} + +const captchaExpiresIn = 600 + +func (p Captcha) CleanUp() error { + _, err := DB["rdb"].Exec("delete from captcha where created_at < ?", time.Now().Unix()-captchaExpiresIn) + return err +} diff --git a/src/modules/rdb/config/yaml.go b/src/modules/rdb/config/yaml.go index 64cbbbdb..f70ad502 100644 --- a/src/modules/rdb/config/yaml.go +++ b/src/modules/rdb/config/yaml.go @@ -18,6 +18,7 @@ type ConfigT struct { Sender map[string]senderSection `yaml:"sender"` RabbitMQ rabbitmqSection `yaml:"rabbitmq"` WeChat wechatSection `yaml:"wechat"` + Captcha bool `yaml:"captcha"` } type wechatSection struct { @@ -33,7 +34,7 @@ type ssoSection struct { ClientId string `yaml:"clientId"` ClientSecret string `yaml:"clientSecret"` ApiKey string `yaml:"apiKey"` - StateExpiresIn int `yaml:"stateExpiresIn"` + StateExpiresIn int64 `yaml:"stateExpiresIn"` CoverAttributes bool `yaml:"coverAttributes"` Attributes struct { Dispname string `yaml:"dispname"` diff --git a/src/modules/rdb/cron/cleaner.go b/src/modules/rdb/cron/cleaner.go new file mode 100644 index 00000000..74ca2686 --- /dev/null +++ b/src/modules/rdb/cron/cleaner.go @@ -0,0 +1,19 @@ +package cron + +import ( + "time" + + "github.com/didi/nightingale/src/models" +) + +const cleanerInterval = 3600 * time.Second + +func CleanerLoop() { + tc := time.Tick(cleanerInterval) + + for { + models.AuthState{}.CleanUp() + models.Captcha{}.CleanUp() + <-tc + } +} diff --git a/src/modules/rdb/http/router.go b/src/modules/rdb/http/router.go index 79e83c2b..df6b5b47 100644 --- a/src/modules/rdb/http/router.go +++ b/src/modules/rdb/http/router.go @@ -18,16 +18,13 @@ func Config(r *gin.Engine) { notLogin.GET("/roles/local", localRoleGet) notLogin.POST("/users/invite", userInvitePost) - notLogin.GET("/auth/authorize", authAuthorize) - notLogin.GET("/auth/callback", authCallback) - notLogin.GET("/auth/settings", authSettings) - notLogin.GET("/auth/v2/authorize", authAuthorizeV2) notLogin.GET("/auth/v2/callback", authCallbackV2) notLogin.GET("/auth/v2/logout", logoutV2) notLogin.POST("/auth/send-rst-code-by-sms", sendRstCodeBySms) notLogin.POST("/auth/rst-password", rstPassword) + notLogin.GET("/auth/captcha", captchaGet) notLogin.GET("/v2/nodes", nodeGets) } diff --git a/src/modules/rdb/http/router_auth.go b/src/modules/rdb/http/router_auth.go index a5054ea5..b7298bc2 100644 --- a/src/modules/rdb/http/router_auth.go +++ b/src/modules/rdb/http/router_auth.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "errors" "fmt" "html/template" "log" @@ -11,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/mojocn/base64Captcha" "github.com/toolkits/pkg/file" "github.com/toolkits/pkg/str" @@ -22,8 +24,19 @@ import ( ) var ( - loginCodeSmsTpl *template.Template - loginCodeEmailTpl *template.Template + loginCodeSmsTpl *template.Template + loginCodeEmailTpl *template.Template + errUnsupportCaptcha = errors.New("unsupported captcha") + + // https://captcha.mojotv.cn + captchaDirver = base64Captcha.DriverString{ + Height: 30, + Width: 120, + ShowLineOptions: 0, + Length: 4, + Source: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + //ShowLineOptions: 14, + } ) func init() { @@ -102,23 +115,6 @@ func logout(c *gin.Context) { } } -func authAuthorize(c *gin.Context) { - username := cookieUsername(c) - if username != "" { // alread login - c.String(200, "hi, "+username) - return - } - - redirect := queryStr(c, "redirect", "/") - - if config.Config.SSO.Enable { - c.Redirect(302, ssoc.Authorize(redirect)) - } else { - c.String(200, "sso does not enable") - } - -} - type authRedirect struct { Redirect string `json:"redirect"` Msg string `json:"msg"` @@ -134,29 +130,13 @@ func authAuthorizeV2(c *gin.Context) { return } + var err error if config.Config.SSO.Enable { - ret.Redirect = ssoc.Authorize(redirect) + ret.Redirect, err = ssoc.Authorize(redirect) } else { ret.Redirect = "/login" } - renderData(c, ret, nil) -} - -func authCallback(c *gin.Context) { - code := queryStr(c, "code", "") - state := queryStr(c, "state", "") - if code == "" { - if redirect := queryStr(c, "redirect"); redirect != "" { - c.Redirect(302, redirect) - return - } - } - - redirect, user, err := ssoc.Callback(code, state) - dangerous(err) - - writeCookieUser(c, user.UUID) - c.Redirect(302, redirect) + renderData(c, ret, err) } func authCallbackV2(c *gin.Context) { @@ -182,14 +162,6 @@ func authCallbackV2(c *gin.Context) { renderData(c, ret, nil) } -func authSettings(c *gin.Context) { - renderData(c, struct { - Sso bool `json:"sso"` - }{ - Sso: config.Config.SSO.Enable, - }, nil) -} - func logoutV2(c *gin.Context) { redirect := queryStr(c, "redirect", "") ret := &authRedirect{Redirect: redirect} @@ -516,3 +488,33 @@ func rstPassword(c *gin.Context) { renderData(c, "reset successfully", nil) } } + +func captchaGet(c *gin.Context) { + ret, err := func() (*models.Captcha, error) { + if !config.Config.Captcha { + return nil, errUnsupportCaptcha + } + + driver := captchaDirver.ConvertFonts() + id, content, answer := driver.GenerateIdQuestionAnswer() + item, err := driver.DrawCaptcha(content) + if err != nil { + return nil, err + } + + ret := &models.Captcha{ + CaptchaId: id, + Answer: answer, + Image: item.EncodeB64string(), + CreatedAt: time.Now().Unix(), + } + + if err := ret.Save(); err != nil { + return nil, err + } + + return ret, nil + }() + + renderData(c, ret, err) +} diff --git a/src/modules/rdb/rdb.go b/src/modules/rdb/rdb.go index 88c7e651..615b208e 100644 --- a/src/modules/rdb/rdb.go +++ b/src/modules/rdb/rdb.go @@ -77,6 +77,7 @@ func main() { go cron.ConsumeSms() go cron.ConsumeVoice() go cron.ConsumeIm() + go cron.CleanerLoop() http.Start() diff --git a/src/modules/rdb/ssoc/sso.go b/src/modules/rdb/ssoc/sso.go index 1ad6861f..44fe6da4 100644 --- a/src/modules/rdb/ssoc/sso.go +++ b/src/modules/rdb/ssoc/sso.go @@ -3,6 +3,7 @@ package ssoc import ( "context" "crypto/tls" + "errors" "fmt" "io" "log" @@ -16,15 +17,18 @@ import ( "github.com/didi/nightingale/src/modules/rdb/config" "github.com/google/uuid" "golang.org/x/oauth2" - "k8s.io/apimachinery/pkg/util/cache" +) + +var ( + errState = errors.New("您的登录信息已过期,请前往首页重新登录..") + errUser = errors.New("用户信息异常") ) type ssoClient struct { verifier *oidc.IDTokenVerifier config oauth2.Config apiKey string - cache *cache.LRUExpireCache - stateExpiresIn time.Duration + stateExpiresIn int64 ssoAddr string callbackAddr string coverAttributes bool @@ -48,7 +52,6 @@ func InitSSO() { return } - cli.cache = cache.NewLRUExpireCache(1000) cli.ssoAddr = cf.SsoAddr cli.callbackAddr = cf.RedirectURL cli.coverAttributes = cf.CoverAttributes @@ -75,19 +78,26 @@ func InitSSO() { } cli.apiKey = cf.ApiKey - if cf.StateExpiresIn == 0 { - cli.stateExpiresIn = time.Second * 60 - } else { - cli.stateExpiresIn = time.Second * time.Duration(cf.StateExpiresIn) + if cli.stateExpiresIn = cf.StateExpiresIn; cli.stateExpiresIn == 0 { + cli.stateExpiresIn = 60 } } // Authorize return the sso authorize location with state -func Authorize(redirect string) string { - state := uuid.New().String() - cli.cache.Add(state, redirect, cli.stateExpiresIn) +func Authorize(redirect string) (string, error) { + state := &models.AuthState{ + State: uuid.New().String(), + Typ: "OAuth2.CODE", + Redirect: redirect, + ExpiresAt: time.Now().Unix() + cli.stateExpiresIn, + } + + if err := state.Save(); err != nil { + return "", err + } + // log.Printf("add state %s", state) - return cli.config.AuthCodeURL(state) + return cli.config.AuthCodeURL(state.State), nil } // LogoutLocation return logout location @@ -100,25 +110,23 @@ func LogoutLocation(redirect string) string { // Callback 用 code 兑换 accessToken 以及 用户信息, func Callback(code, state string) (string, *models.User, error) { - s, ok := cli.cache.Get(state) - if !ok { - return "", nil, fmt.Errorf("invalid state %s", state) + s, err := models.AuthStateGet("state=?", state) + if err != nil { + return "", nil, errState } - cli.cache.Remove(state) - // log.Printf("remove state %s", state) - redirect := s.(string) - // log.Printf("callback, get state %s redirect %s", state, redirect) + s.Del() + // log.Printf("remove state %s", state) u, err := exchangeUser(code) if err != nil { - return "", nil, err + return "", nil, errUser } // log.Printf("exchange user %v", u) user, err := models.UserGet("username=?", u.Username) if err != nil { - return "", nil, err + return "", nil, errUser } if user == nil { @@ -132,7 +140,7 @@ func Callback(code, state string) (string, *models.User, error) { err = user.Update("email", "dispname", "phone", "im") } - return redirect, user, err + return s.Redirect, user, err } func exchangeUser(code string) (*models.User, error) {