From 2226872e32967cacf46d321bcc393b1abb71edfb Mon Sep 17 00:00:00 2001 From: Wenxuan Date: Sat, 17 Jul 2021 01:23:20 +0800 Subject: [PATCH] Release v2021.07.17.1 (#961) *: Add OIDC SSO support (#960) --- go.mod | 2 + go.sum | 11 +- pkg/apiserver/apiserver.go | 12 +- pkg/apiserver/configuration/router.go | 2 +- pkg/apiserver/info/info.go | 12 +- pkg/apiserver/metrics/router.go | 2 +- pkg/apiserver/profiling/router.go | 2 +- pkg/apiserver/queryeditor/service.go | 2 +- pkg/apiserver/statement/service.go | 2 +- pkg/apiserver/user/auth.go | 265 ++++++----- pkg/apiserver/user/code/codeauth/auth.go | 49 ++ pkg/apiserver/user/code/router.go | 56 +++ pkg/apiserver/user/code/service.go | 107 +++++ pkg/apiserver/user/sqlauth/sqlauth.go | 62 +++ pkg/apiserver/user/sso/models.go | 40 ++ pkg/apiserver/user/sso/router.go | 163 +++++++ pkg/apiserver/user/sso/service.go | 440 ++++++++++++++++++ pkg/apiserver/user/sso/ssoauth/auth.go | 66 +++ pkg/apiserver/utils/auth.go | 99 ++-- pkg/apiserver/utils/error.go | 4 +- pkg/apiserver/utils/tidb_conn.go | 2 +- pkg/config/dynamic_config.go | 16 + pkg/config/dynamic_config_manager.go | 2 +- pkg/keyvisual/service.go | 2 +- release-version | 2 +- ui/dashboardApp/index.ts | 63 ++- ui/dashboardApp/layout/main/Sider/Banner.tsx | 17 +- ui/dashboardApp/layout/main/Sider/index.tsx | 19 +- ui/dashboardApp/layout/signin/index.tsx | 100 +++- ui/dashboardApp/layout/translations/en.yaml | 5 + ui/dashboardApp/layout/translations/zh.yaml | 5 + .../KeyViz/components/KeyVizSettingForm.tsx | 16 +- .../pages/List/StatementSettingForm.tsx | 16 +- ui/lib/apps/UserProfile/Form.Language.tsx | 33 ++ .../apps/UserProfile/Form.PrometheusAddr.tsx | 142 ++++++ ui/lib/apps/UserProfile/Form.SSO.tsx | 316 +++++++++++++ ui/lib/apps/UserProfile/Form.Session.tsx | 221 +++++++++ ui/lib/apps/UserProfile/Form.Version.tsx | 66 +++ ui/lib/apps/UserProfile/constants.tsx | 1 + ui/lib/apps/UserProfile/index.tsx | 435 +---------------- ui/lib/apps/UserProfile/translations/en.yaml | 29 +- ui/lib/apps/UserProfile/translations/zh.yaml | 29 +- ui/lib/client/index.tsx | 2 +- ui/lib/client/translations/en.yaml | 2 +- ui/lib/client/translations/zh.yaml | 2 +- ui/lib/components/CopyLink/index.tsx | 2 +- ui/lib/utils/auth.ts | 24 +- ui/lib/utils/authSSO.ts | 78 ++++ ui/lib/utils/sentryHelpers.ts | 10 +- ui/lib/utils/store.ts | 53 +++ ui/package.json | 3 + ui/yarn.lock | 31 +- 52 files changed, 2443 insertions(+), 699 deletions(-) create mode 100644 pkg/apiserver/user/code/codeauth/auth.go create mode 100644 pkg/apiserver/user/code/router.go create mode 100644 pkg/apiserver/user/code/service.go create mode 100644 pkg/apiserver/user/sqlauth/sqlauth.go create mode 100644 pkg/apiserver/user/sso/models.go create mode 100644 pkg/apiserver/user/sso/router.go create mode 100644 pkg/apiserver/user/sso/service.go create mode 100644 pkg/apiserver/user/sso/ssoauth/auth.go create mode 100644 ui/lib/apps/UserProfile/Form.Language.tsx create mode 100644 ui/lib/apps/UserProfile/Form.PrometheusAddr.tsx create mode 100644 ui/lib/apps/UserProfile/Form.SSO.tsx create mode 100644 ui/lib/apps/UserProfile/Form.Session.tsx create mode 100644 ui/lib/apps/UserProfile/Form.Version.tsx create mode 100644 ui/lib/apps/UserProfile/constants.tsx create mode 100644 ui/lib/utils/authSSO.ts create mode 100644 ui/lib/utils/store.ts diff --git a/go.mod b/go.mod index dcd8603cf6..cd86697c22 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gin-contrib/gzip v0.0.1 github.com/gin-gonic/gin v1.5.0 + github.com/go-resty/resty/v2 v2.6.0 github.com/go-sql-driver/mysql v1.6.0 github.com/goccy/go-graphviz v0.0.5 github.com/google/pprof v0.0.0-20200407044318-7d83b28da2e9 @@ -40,6 +41,7 @@ require ( go.uber.org/atomic v1.6.0 go.uber.org/fx v1.10.0 go.uber.org/zap v1.16.0 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be golang.org/x/sync v0.0.0-20210220032951-036812b2e83c google.golang.org/grpc v1.25.1 gorm.io/driver/mysql v1.0.6 diff --git a/go.sum b/go.sum index 1eb3eb26aa..34d1eaf74c 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg github.com/go-playground/overalls v0.0.0-20180201144345-22ec1a223b7c/go.mod h1:UqxAgEOt89sCiXlrc/ycnx00LVvUO/eS8tMUkWX4R7w= github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= +github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -396,8 +398,10 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -420,8 +424,11 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210217105451-b926d437f341 h1:2/QtM1mL37YmcsT8HaDNHDgTqqFVw+zr8UzMiBVLzYU= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index ded2fc9740..b18ac1134a 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -34,6 +34,11 @@ import ( "github.com/pingcap/tidb-dashboard/pkg/apiserver/metrics" "github.com/pingcap/tidb-dashboard/pkg/apiserver/profiling" "github.com/pingcap/tidb-dashboard/pkg/apiserver/queryeditor" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/code" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/code/codeauth" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/sqlauth" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/sso" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/sso/ssoauth" "github.com/pingcap/tidb-dashboard/pkg/tiflash" // "github.com/pingcap/tidb-dashboard/pkg/apiserver/__APP_NAME__" @@ -130,10 +135,15 @@ func (s *Service) Start(ctx context.Context) error { // __APP_NAME__.NewService, // NOTE: Don't remove above comment line, it is a placeholder for code generator ), + codeauth.Module, + sqlauth.Module, + ssoauth.Module, + code.Module, + sso.Module, + profiling.Module, statement.Module, slowquery.Module, debugapi.Module, - profiling.Module, fx.Populate(&s.apiHandlerEngine), fx.Invoke( user.RegisterRouter, diff --git a/pkg/apiserver/configuration/router.go b/pkg/apiserver/configuration/router.go index 04698f5626..21b874099e 100644 --- a/pkg/apiserver/configuration/router.go +++ b/pkg/apiserver/configuration/router.go @@ -28,7 +28,7 @@ func RegisterRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient)) endpoint.Use(utils.MWForbidByExperimentalFlag(s.params.Config.EnableExperimental)) endpoint.GET("/all", s.getHandler) - endpoint.POST("/edit", s.editHandler) + endpoint.POST("/edit", auth.MWRequireWritePriv(), s.editHandler) } // @ID configurationGetAll diff --git a/pkg/apiserver/info/info.go b/pkg/apiserver/info/info.go index 5deddb5401..e85eed1b4c 100644 --- a/pkg/apiserver/info/info.go +++ b/pkg/apiserver/info/info.go @@ -78,8 +78,9 @@ func (s *Service) infoHandler(c *gin.Context) { } type WhoAmIResponse struct { - Username string `json:"username"` - IsShared bool `json:"is_shared"` + DisplayName string `json:"display_name"` + IsShareable bool `json:"is_shareable"` + IsWriteable bool `json:"is_writeable"` } // @ID infoWhoami @@ -89,10 +90,11 @@ type WhoAmIResponse struct { // @Security JwtAuth // @Failure 401 {object} utils.APIError "Unauthorized failure" func (s *Service) whoamiHandler(c *gin.Context) { - sessionUser := c.MustGet(utils.SessionUserKey).(*utils.SessionUser) + sessionUser := utils.GetSession(c) resp := WhoAmIResponse{ - Username: sessionUser.TiDBUsername, - IsShared: sessionUser.IsShared, + DisplayName: sessionUser.DisplayName, + IsShareable: sessionUser.IsShareable, + IsWriteable: sessionUser.IsWriteable, } c.JSON(http.StatusOK, resp) } diff --git a/pkg/apiserver/metrics/router.go b/pkg/apiserver/metrics/router.go index 1060ff9116..a60c3c02ca 100644 --- a/pkg/apiserver/metrics/router.go +++ b/pkg/apiserver/metrics/router.go @@ -43,7 +43,7 @@ func RegisterRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.Use(auth.MWAuthRequired()) endpoint.GET("/query", s.queryMetrics) endpoint.GET("/prom_address", s.getPromAddressConfig) - endpoint.PUT("/prom_address", s.putCustomPromAddress) + endpoint.PUT("/prom_address", auth.MWRequireWritePriv(), s.putCustomPromAddress) } // @Summary Query metrics diff --git a/pkg/apiserver/profiling/router.go b/pkg/apiserver/profiling/router.go index 470b8577a9..1ddf2ec793 100644 --- a/pkg/apiserver/profiling/router.go +++ b/pkg/apiserver/profiling/router.go @@ -44,7 +44,7 @@ func RegisterRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.GET("/single/view", s.viewSingle) endpoint.GET("/config", auth.MWAuthRequired(), s.getDynamicConfig) - endpoint.PUT("/config", auth.MWAuthRequired(), s.setDynamicConfig) + endpoint.PUT("/config", auth.MWAuthRequired(), auth.MWRequireWritePriv(), s.setDynamicConfig) } // @ID startProfiling diff --git a/pkg/apiserver/queryeditor/service.go b/pkg/apiserver/queryeditor/service.go index e30227510a..ec28c95fb0 100644 --- a/pkg/apiserver/queryeditor/service.go +++ b/pkg/apiserver/queryeditor/service.go @@ -58,7 +58,7 @@ func RegisterRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.Use(auth.MWAuthRequired()) endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient)) endpoint.Use(utils.MWForbidByExperimentalFlag(s.params.Config.EnableExperimental)) - endpoint.POST("/run", s.runHandler) + endpoint.POST("/run", auth.MWRequireWritePriv(), s.runHandler) } type RunRequest struct { diff --git a/pkg/apiserver/statement/service.go b/pkg/apiserver/statement/service.go index a7ce2c0a7e..360e22db4a 100644 --- a/pkg/apiserver/statement/service.go +++ b/pkg/apiserver/statement/service.go @@ -60,7 +60,7 @@ func registerRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient)) { endpoint.GET("/config", s.configHandler) - endpoint.POST("/config", s.modifyConfigHandler) + endpoint.POST("/config", auth.MWRequireWritePriv(), s.modifyConfigHandler) endpoint.GET("/time_ranges", s.timeRangesHandler) endpoint.GET("/stmt_types", s.stmtTypesHandler) endpoint.GET("/list", s.listHandler) diff --git a/pkg/apiserver/user/auth.go b/pkg/apiserver/user/auth.go index f014de3d54..7112ecbddc 100644 --- a/pkg/apiserver/user/auth.go +++ b/pkg/apiserver/user/auth.go @@ -19,6 +19,7 @@ import ( "errors" "net/http" "os" + "sort" "time" jwt "github.com/appleboy/gin-jwt/v2" @@ -29,35 +30,25 @@ import ( "go.uber.org/zap" "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" - "github.com/pingcap/tidb-dashboard/pkg/tidb" ) var ( - ErrNS = errorx.NewNamespace("error.api.user") - ErrNSSignIn = ErrNS.NewSubNamespace("signin") - ErrSignInUnsupportedAuthType = ErrNSSignIn.NewType("unsupported_auth_type") - ErrSignInOther = ErrNSSignIn.NewType("other") - ErrSignInInvalidCode = ErrNSSignIn.NewType("invalid_code") // Invalid or expired - ErrShareFailed = ErrNS.NewType("share_failed") + ErrNS = errorx.NewNamespace("error.api.user") + ErrUnsupportedAuthType = ErrNS.NewType("unsupported_auth_type") + ErrNSSignIn = ErrNS.NewSubNamespace("signin") + ErrSignInOther = ErrNSSignIn.NewType("other") ) type AuthService struct { - middleware *jwt.GinJWTMiddleware - tidbClient *tidb.Client + middleware *jwt.GinJWTMiddleware + authenticators map[utils.AuthType]Authenticator } -type AuthType int - -const ( - AuthTypeSQLUser AuthType = iota - AuthTypeSharingCode - // TODO: Add more auth type -) - -type authenticateForm struct { - Type AuthType `json:"type" example:"0"` - Username string `json:"username" example:"root"` // Does not present for AuthTypeSharingCode - Password string `json:"password"` +type AuthenticateForm struct { + Type utils.AuthType `json:"type" example:"0"` + Username string `json:"username" example:"root"` // Does not present for AuthTypeSharingCode + Password string `json:"password"` + Extra string `json:"extra"` // FIXME: Use strong type } type TokenResponse struct { @@ -65,7 +56,32 @@ type TokenResponse struct { Expire time.Time `json:"expire"` } -func NewAuthService(tidbClient *tidb.Client) *AuthService { +type SignOutInfo struct { + EndSessionURL string `json:"end_session_url"` +} + +type Authenticator interface { + IsEnabled() (bool, error) + Authenticate(form AuthenticateForm) (*utils.SessionUser, error) + ProcessSession(u *utils.SessionUser) bool + SignOutInfo(u *utils.SessionUser, redirectURL string) (*SignOutInfo, error) +} + +type BaseAuthenticator struct{} + +func (a BaseAuthenticator) IsEnabled() (bool, error) { + return true, nil +} + +func (a BaseAuthenticator) ProcessSession(u *utils.SessionUser) bool { + return true +} + +func (a BaseAuthenticator) SignOutInfo(u *utils.SessionUser, redirectURL string) (*SignOutInfo, error) { + return &SignOutInfo{}, nil +} + +func NewAuthService() *AuthService { var secret *[32]byte secretStr := os.Getenv("DASHBOARD_SESSION_SECRET") @@ -82,8 +98,8 @@ func NewAuthService(tidbClient *tidb.Client) *AuthService { } service := &AuthService{ - middleware: nil, - tidbClient: tidbClient, + middleware: nil, + authenticators: map[utils.AuthType]Authenticator{}, } middleware, err := jwt.New(&jwt.GinJWTMiddleware{ @@ -93,11 +109,11 @@ func NewAuthService(tidbClient *tidb.Client) *AuthService { Timeout: time.Hour * 24, MaxRefresh: time.Hour * 24, Authenticator: func(c *gin.Context) (interface{}, error) { - var form authenticateForm + var form AuthenticateForm if err := c.ShouldBindJSON(&form); err != nil { return nil, utils.ErrInvalidRequest.WrapWithNoMessage(err) } - u, err := service.authForm(&form) + u, err := service.authForm(form) if err != nil { return nil, errorx.Decorate(err, "authenticate failed") } @@ -141,6 +157,20 @@ func NewAuthService(tidbClient *tidb.Client) *AuthService { if err := json.Unmarshal(decrypted, &user); err != nil { return nil } + + // Force expire schema outdated sessions. + if user.Version != utils.SessionVersion { + return nil + } + + a, ok := service.authenticators[user.AuthFrom] + if !ok { + return nil + } + if !a.ProcessSession(&user) { + return nil + } + return &user }, Authorizator: func(data interface{}, c *gin.Context) bool { @@ -149,13 +179,7 @@ func NewAuthService(tidbClient *tidb.Client) *AuthService { return false } user := data.(*utils.SessionUser) - if user == nil { - return false - } - if user.IsShared && time.Now().After(user.SharedSessionExpireAt) { - return false - } - return true + return user != nil }, HTTPStatusMessageFunc: func(e error, c *gin.Context) string { var err error @@ -193,62 +217,24 @@ func NewAuthService(tidbClient *tidb.Client) *AuthService { return service } -func (s *AuthService) authForm(f *authenticateForm) (*utils.SessionUser, error) { - switch f.Type { - case AuthTypeSQLUser: - return s.authSQLForm(f) - case AuthTypeSharingCode: - return s.authSharingCodeForm(f) - default: - return nil, ErrSignInUnsupportedAuthType.NewWithNoMessage() - } -} - -func (s *AuthService) authSQLForm(f *authenticateForm) (*utils.SessionUser, error) { - if f.Type != AuthTypeSQLUser { - panic("Expect AuthTypeSQLUser") +func (s *AuthService) authForm(f AuthenticateForm) (*utils.SessionUser, error) { + a, ok := s.authenticators[f.Type] + if !ok { + return nil, ErrUnsupportedAuthType.NewWithNoMessage() } - // Currently we don't support privileges, so only root user is allowed to sign in. - if f.Username != "root" { - return nil, ErrSignInOther.New("non root user is not allowed") - } - db, err := s.tidbClient.OpenSQLConn(f.Username, f.Password) + u, err := a.Authenticate(f) if err != nil { - if errorx.Cast(err) == nil { - return nil, ErrSignInOther.WrapWithNoMessage(err) - } - // Possible errors could be: - // tidb.ErrNoAliveTiDB - // tidb.ErrPDAccessFailed - // tidb.ErrTiDBConnFailed - // tidb.ErrTiDBAuthFailed return nil, err } - defer utils.CloseTiDBConnection(db) //nolint:errcheck - - return &utils.SessionUser{ - HasTiDBAuth: true, - TiDBUsername: f.Username, - TiDBPassword: f.Password, - IsShared: false, - }, nil -} - -func (s *AuthService) authSharingCodeForm(f *authenticateForm) (*utils.SessionUser, error) { - if f.Type != AuthTypeSharingCode { - panic("Expect AuthTypeSharingCode") - } - session := utils.NewSessionFromSharingCode(f.Password) - if session == nil { - return nil, ErrSignInInvalidCode.NewWithNoMessage() - } - return session, nil + u.AuthFrom = f.Type + return u, nil } func RegisterRouter(r *gin.RouterGroup, s *AuthService) { endpoint := r.Group("/user") + endpoint.GET("/login_info", s.getLoginInfoHandler) endpoint.POST("/login", s.loginHandler) - endpoint.POST("/share", s.MWAuthRequired(), s.shareSessionHandler) + endpoint.GET("/sign_out_info", s.MWAuthRequired(), s.getSignOutInfoHandler) } // MWAuthRequired creates a middleware that verifies the authentication token (JWT) in the request. If the token @@ -258,9 +244,76 @@ func (s *AuthService) MWAuthRequired() gin.HandlerFunc { return s.middleware.MiddlewareFunc() } +// TODO: Make these MWRequireXxxPriv more general to use. +func (s *AuthService) MWRequireSharePriv() gin.HandlerFunc { + return func(c *gin.Context) { + u := utils.GetSession(c) + if u == nil { + utils.MakeUnauthorizedError(c) + c.Abort() + return + } + if !u.IsShareable { + utils.MakeInsufficientPrivilegeError(c) + c.Abort() + return + } + c.Next() + } +} + +func (s *AuthService) MWRequireWritePriv() gin.HandlerFunc { + return func(c *gin.Context) { + u := utils.GetSession(c) + if u == nil { + utils.MakeUnauthorizedError(c) + c.Abort() + return + } + if !u.IsWriteable { + utils.MakeInsufficientPrivilegeError(c) + c.Abort() + return + } + c.Next() + } +} + +// RegisterAuthenticator registers an authenticator in the authenticate pipeline. +func (s *AuthService) RegisterAuthenticator(typeID utils.AuthType, a Authenticator) { + s.authenticators[typeID] = a +} + +type GetLoginInfoResponse struct { + SupportedAuthTypes []int `json:"supported_auth_types"` +} + +// @ID userGetLoginInfo +// @Summary Get log in information, like supported authenticate types. +// @Success 200 {object} GetLoginInfoResponse +// @Router /user/login_info [get] +func (s *AuthService) getLoginInfoHandler(c *gin.Context) { + supportedAuth := make([]int, 0) + for typeID, a := range s.authenticators { + enabled, err := a.IsEnabled() + if err != nil { + _ = c.Error(err) + return + } + if enabled { + supportedAuth = append(supportedAuth, int(typeID)) + } + } + sort.Ints(supportedAuth) + resp := GetLoginInfoResponse{ + SupportedAuthTypes: supportedAuth, + } + c.JSON(http.StatusOK, resp) +} + // @ID userLogin // @Summary Log in -// @Param message body authenticateForm true "Credentials" +// @Param message body AuthenticateForm true "Credentials" // @Success 200 {object} TokenResponse // @Failure 401 {object} utils.APIError // @Router /user/login [post] @@ -268,45 +321,35 @@ func (s *AuthService) loginHandler(c *gin.Context) { s.middleware.LoginHandler(c) } -type ShareRequest struct { - ExpireInSeconds int64 `json:"expire_in_sec"` +type GetSignOutInfoRequest struct { + RedirectURL string `json:"redirect_url" form:"redirect_url"` } -type ShareResponse struct { - Code string `json:"code"` -} - -// @ID userShareSession -// @Summary Share current session and generate a sharing code -// @Param request body ShareRequest true "Request body" +// @ID userGetSignOutInfo +// @Summary Get sign out info +// @Success 200 {object} SignOutInfo +// @Param q query GetSignOutInfoRequest true "Query" +// @Router /user/sign_out_info [get] // @Security JwtAuth -// @Success 200 {object} ShareResponse -// @Router /user/share [post] -func (s *AuthService) shareSessionHandler(c *gin.Context) { - var req ShareRequest - if err := c.ShouldBindJSON(&req); err != nil { +// @Failure 401 {object} utils.APIError "Unauthorized failure" +// @Failure 500 {object} utils.APIError "Internal error" +func (s *AuthService) getSignOutInfoHandler(c *gin.Context) { + var req GetSignOutInfoRequest + if err := c.ShouldBindQuery(&req); err != nil { utils.MakeInvalidRequestErrorFromError(c, err) return } - expiry := time.Second * time.Duration(req.ExpireInSeconds) - - if expiry > utils.MaxSessionShareExpiry || expiry < 0 { - utils.MakeInvalidRequestErrorWithMessage(c, "Invalid share expiry") - return - } - - sessionUser := c.MustGet(utils.SessionUserKey).(*utils.SessionUser) - if sessionUser.IsShared { - utils.MakeInvalidRequestErrorWithMessage(c, "Shared session cannot be shared again") + u := utils.GetSession(c) + a, ok := s.authenticators[u.AuthFrom] + if !ok { + _ = c.Error(ErrUnsupportedAuthType.NewWithNoMessage()) return } - - code := sessionUser.ToSharingCode(expiry) - if code == nil { - _ = c.Error(ErrShareFailed.NewWithNoMessage()) + si, err := a.SignOutInfo(u, req.RedirectURL) + if err != nil { + _ = c.Error(err) return } - - c.JSON(http.StatusOK, ShareResponse{Code: *code}) + c.JSON(http.StatusOK, si) } diff --git a/pkg/apiserver/user/code/codeauth/auth.go b/pkg/apiserver/user/code/codeauth/auth.go new file mode 100644 index 0000000000..0357326e96 --- /dev/null +++ b/pkg/apiserver/user/code/codeauth/auth.go @@ -0,0 +1,49 @@ +package codeauth + +import ( + "time" + + "go.uber.org/fx" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/code" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" +) + +const typeID utils.AuthType = 1 + +var ( + ErrSignInInvalidCode = user.ErrNSSignIn.NewType("invalid_code") // Invalid or expired +) + +type Authenticator struct { + user.BaseAuthenticator + sharingCodeService *code.Service +} + +func newAuthenticator(sharingCodeService *code.Service) *Authenticator { + return &Authenticator{ + sharingCodeService: sharingCodeService, + } +} + +func registerAuthenticator(a *Authenticator, authService *user.AuthService) { + authService.RegisterAuthenticator(typeID, a) +} + +var Module = fx.Options( + fx.Provide(newAuthenticator), + fx.Invoke(registerAuthenticator), +) + +func (a *Authenticator) Authenticate(f user.AuthenticateForm) (*utils.SessionUser, error) { + session := a.sharingCodeService.NewSessionFromSharingCode(f.Password) + if session == nil { + return nil, ErrSignInInvalidCode.NewWithNoMessage() + } + return session, nil +} + +func (a *Authenticator) ProcessSession(user *utils.SessionUser) bool { + return !time.Now().After(user.SharedSessionExpireAt) +} diff --git a/pkg/apiserver/user/code/router.go b/pkg/apiserver/user/code/router.go new file mode 100644 index 0000000000..c903ba23d3 --- /dev/null +++ b/pkg/apiserver/user/code/router.go @@ -0,0 +1,56 @@ +package code + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" +) + +func registerRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { + endpoint := r.Group("/user/share") + endpoint.Use(auth.MWAuthRequired()) + endpoint.POST("/code", auth.MWRequireSharePriv(), s.shareHandler) +} + +type ShareRequest struct { + ExpireInSeconds int64 `json:"expire_in_sec"` + RevokeWritePriv bool `json:"revoke_write_priv"` +} + +type ShareResponse struct { + Code string `json:"code"` +} + +// @ID userShareSession +// @Summary Share current session and generate a sharing code +// @Param request body ShareRequest true "Request body" +// @Security JwtAuth +// @Success 200 {object} ShareResponse +// @Router /user/share/code [post] +func (s *Service) shareHandler(c *gin.Context) { + var req ShareRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.MakeInvalidRequestErrorFromError(c, err) + return + } + + expiry := time.Second * time.Duration(req.ExpireInSeconds) + + if expiry > MaxSessionShareExpiry || expiry < 0 { + utils.MakeInvalidRequestErrorWithMessage(c, "Invalid share expiry") + return + } + + sessionUser := utils.GetSession(c) + code := s.SharingCodeFromSession(sessionUser, expiry, req.RevokeWritePriv) + if code == nil { + _ = c.Error(ErrShareFailed.New("Share session failed")) + return + } + + c.JSON(http.StatusOK, ShareResponse{Code: *code}) +} diff --git a/pkg/apiserver/user/code/service.go b/pkg/apiserver/user/code/service.go new file mode 100644 index 0000000000..84f5713232 --- /dev/null +++ b/pkg/apiserver/user/code/service.go @@ -0,0 +1,107 @@ +package code + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/gtank/cryptopasta" + "github.com/joomcode/errorx" + "github.com/vmihailenco/msgpack/v5" + "go.uber.org/fx" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" +) + +var ( + ErrNS = errorx.NewNamespace("error.api.user.code") + ErrShareFailed = ErrNS.NewType("share_failed") +) + +const ( + // Max permitted lifetime of a shared session. + MaxSessionShareExpiry = time.Hour * 24 * 30 +) + +type Service struct { + sharingSecret *[32]byte +} + +type sharedSession struct { + Session *utils.SessionUser + ExpireAt time.Time + RevokeWritePriv bool +} + +func newService() *Service { + return &Service{ + sharingSecret: cryptopasta.NewEncryptionKey(), + } +} + +var Module = fx.Options( + fx.Provide(newService), + fx.Invoke(registerRouter), +) + +func (s *Service) NewSessionFromSharingCode(codeInHex string) *utils.SessionUser { + encrypted, err := hex.DecodeString(codeInHex) + if err != nil { + return nil + } + + b, err := cryptopasta.Decrypt(encrypted, s.sharingSecret) + if err != nil { + return nil + } + + var shared sharedSession + if err := msgpack.Unmarshal(b, &shared); err != nil { + return nil + } + + if time.Now().After(shared.ExpireAt) { + return nil + } + + shared.Session.SharedSessionExpireAt = shared.ExpireAt + shared.Session.DisplayName = fmt.Sprintf("Shared from %s", shared.Session.DisplayName) + shared.Session.IsShareable = false + if shared.RevokeWritePriv { + shared.Session.IsWriteable = false + } + + return shared.Session +} + +func (s *Service) SharingCodeFromSession(session *utils.SessionUser, expireIn time.Duration, revokeWritePriv bool) *string { + if !session.IsShareable { + return nil + } + if expireIn < 0 { + return nil + } + if expireIn > MaxSessionShareExpiry { + return nil + } + + shared := sharedSession{ + Session: session, + ExpireAt: time.Now().Add(expireIn), + RevokeWritePriv: revokeWritePriv, + } + + b, err := msgpack.Marshal(&shared) + if err != nil { + // Do not output anything about how serialization is failed to avoid potential leaks. + return nil + } + + encrypted, err := cryptopasta.Encrypt(b, s.sharingSecret) + if err != nil { + return nil + } + + codeInHex := hex.EncodeToString(encrypted) + return &codeInHex +} diff --git a/pkg/apiserver/user/sqlauth/sqlauth.go b/pkg/apiserver/user/sqlauth/sqlauth.go new file mode 100644 index 0000000000..9b5cd465d3 --- /dev/null +++ b/pkg/apiserver/user/sqlauth/sqlauth.go @@ -0,0 +1,62 @@ +package sqlauth + +import ( + "github.com/joomcode/errorx" + "go.uber.org/fx" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" + "github.com/pingcap/tidb-dashboard/pkg/tidb" +) + +const typeID utils.AuthType = 0 + +type Authenticator struct { + user.BaseAuthenticator + tidbClient *tidb.Client +} + +func newAuthenticator(tidbClient *tidb.Client) *Authenticator { + return &Authenticator{ + tidbClient: tidbClient, + } +} + +func registerAuthenticator(a *Authenticator, authService *user.AuthService) { + authService.RegisterAuthenticator(typeID, a) +} + +var Module = fx.Options( + fx.Provide(newAuthenticator), + fx.Invoke(registerAuthenticator), +) + +func (a *Authenticator) Authenticate(f user.AuthenticateForm) (*utils.SessionUser, error) { + // Currently we don't support privileges, so only root user is allowed to sign in. + if f.Username != "root" { + return nil, user.ErrSignInOther.New("non root user is not allowed") + } + db, err := a.tidbClient.OpenSQLConn(f.Username, f.Password) + if err != nil { + if errorx.Cast(err) == nil { + return nil, user.ErrSignInOther.WrapWithNoMessage(err) + } + // Possible errors could be: + // tidb.ErrNoAliveTiDB + // tidb.ErrPDAccessFailed + // tidb.ErrTiDBConnFailed + // tidb.ErrTiDBAuthFailed + return nil, err + } + defer utils.CloseTiDBConnection(db) //nolint:errcheck + + return &utils.SessionUser{ + Version: utils.SessionVersion, + HasTiDBAuth: true, + TiDBUsername: f.Username, + TiDBPassword: f.Password, + DisplayName: f.Username, + IsShareable: true, + IsWriteable: true, + }, nil +} diff --git a/pkg/apiserver/user/sso/models.go b/pkg/apiserver/user/sso/models.go new file mode 100644 index 0000000000..b81b27c42f --- /dev/null +++ b/pkg/apiserver/user/sso/models.go @@ -0,0 +1,40 @@ +// Copyright 2021 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package sso + +import ( + "github.com/pingcap/tidb-dashboard/pkg/dbstore" +) + +type ImpersonateStatus string + +const ( + ImpersonateStatusSuccess ImpersonateStatus = "success" + ImpersonateStatusAuthFail ImpersonateStatus = "auth_fail" +) + +type SSOImpersonationModel struct { //nolint:golint + SQLUser string `gorm:"primary_key;size:128" json:"sql_user"` + // The encryption key is placed somewhere else in the FS, to avoid being collected by diagnostics collecting tools. + EncryptedPass string `gorm:"type:text" json:"-"` + LastImpersonateStatus *ImpersonateStatus `gorm:"size:32" json:"last_impersonate_status"` +} + +func (SSOImpersonationModel) TableName() string { + return "sso_impersonation" +} + +func autoMigrate(db *dbstore.DB) error { + return db.AutoMigrate(&SSOImpersonationModel{}) +} diff --git a/pkg/apiserver/user/sso/router.go b/pkg/apiserver/user/sso/router.go new file mode 100644 index 0000000000..f6316eab32 --- /dev/null +++ b/pkg/apiserver/user/sso/router.go @@ -0,0 +1,163 @@ +package sso + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/joomcode/errorx" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" + "github.com/pingcap/tidb-dashboard/pkg/config" +) + +func registerRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { + endpoint := r.Group("/user/sso") + endpoint.GET("/auth_url", s.getAuthURLHandler) + endpoint.Use(auth.MWAuthRequired()) + // TODO: Forbid modifying config when signed in as SSO. + endpoint.GET("/impersonations/list", s.listImpersonationHandler) + endpoint.POST("/impersonation", auth.MWRequireWritePriv(), s.createImpersonationHandler) + endpoint.GET("/config", s.getConfig) + endpoint.PUT("/config", auth.MWRequireWritePriv(), s.setConfig) +} + +type GetAuthURLRequest struct { + RedirectURL string `json:"redirect_url" form:"redirect_url"` + CodeVerifier string `json:"code_verifier" form:"code_verifier"` + State string `json:"state" form:"state"` +} + +// @ID userSSOGetAuthURL +// @Summary Get SSO Auth URL +// @Param q query GetAuthURLRequest true "Query" +// @Success 200 {string} string +// @Router /user/sso/auth_url [get] +func (s *Service) getAuthURLHandler(c *gin.Context) { + var req GetAuthURLRequest + if err := c.ShouldBindQuery(&req); err != nil { + utils.MakeInvalidRequestErrorFromError(c, err) + return + } + authURL, err := s.buildOAuthURL(req.RedirectURL, req.State, req.CodeVerifier) + if err != nil { + _ = c.Error(err) + return + } + c.String(http.StatusOK, authURL) +} + +// @ID userSSOListImpersonations +// @Summary List all impersonations +// @Success 200 {array} SSOImpersonationModel +// @Router /user/sso/impersonations/list [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" +func (s *Service) listImpersonationHandler(c *gin.Context) { + var resp []SSOImpersonationModel + err := s.params.LocalStore.Find(&resp).Error + if err != nil { + _ = c.Error(err) + return + } + c.JSON(http.StatusOK, resp) +} + +type CreateImpersonationRequest struct { + SQLUser string `json:"sql_user"` + Password string `json:"password"` +} + +// @ID userSSOCreateImpersonation +// @Summary Create an impersonation +// @Param request body CreateImpersonationRequest true "Request body" +// @Success 200 {object} SSOImpersonationModel +// @Router /user/sso/impersonation [post] +// @Security JwtAuth +// @Failure 400 {object} utils.APIError "Bad request" +// @Failure 401 {object} utils.APIError "Unauthorized failure" +// @Failure 500 {object} utils.APIError "Internal error" +func (s *Service) createImpersonationHandler(c *gin.Context) { + var req CreateImpersonationRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.MakeInvalidRequestErrorFromError(c, err) + return + } + + rec, err := s.createImpersonation(req.SQLUser, req.Password) + if err != nil { + _ = c.Error(err) + if errorx.IsOfType(err, ErrUnsupportedUser) || errorx.IsOfType(err, ErrInvalidImpersonateCredential) { + c.Status(http.StatusBadRequest) + } + return + } + + c.JSON(http.StatusOK, rec) +} + +// @ID userSSOGetConfig +// @Summary Get SSO config +// @Success 200 {object} config.SSOCoreConfig +// @Router /user/sso/config [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" +// @Failure 500 {object} utils.APIError +func (s *Service) getConfig(c *gin.Context) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + _ = c.Error(err) + return + } + c.JSON(http.StatusOK, dc.SSO.CoreConfig) +} + +type SetConfigRequest struct { + Config config.SSOCoreConfig `json:"config"` +} + +// @ID userSSOSetConfig +// @Summary Set SSO config +// @Param request body SetConfigRequest true "Request body" +// @Success 200 {object} config.SSOCoreConfig +// @Router /user/sso/config [put] +// @Security JwtAuth +// @Failure 400 {object} utils.APIError "Bad request" +// @Failure 401 {object} utils.APIError "Unauthorized failure" +// @Failure 500 {object} utils.APIError "Internal error" +func (s *Service) setConfig(c *gin.Context) { + var req SetConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.MakeInvalidRequestErrorFromError(c, err) + return + } + + dConfig := config.SSOConfig{CoreConfig: req.Config} + if req.Config.Enabled { + wellKnownConfig, err := s.discoverOIDC(req.Config.DiscoveryURL) + if err != nil { + _ = c.Error(err) + c.Status(http.StatusBadRequest) + return + } + dConfig.AuthURL = wellKnownConfig.AuthURL + dConfig.TokenURL = wellKnownConfig.TokenURL + dConfig.UserInfoURL = wellKnownConfig.UserInfoURL + dConfig.SignOutURL = wellKnownConfig.EndSessionURL // This is optional + } else { + err := s.revokeAllImpersonations() + if err != nil { + _ = c.Error(err) + return + } + } + + var opt config.DynamicConfigOption = func(dc *config.DynamicConfig) { + dc.SSO = dConfig + } + if err := s.params.ConfigManager.Modify(opt); err != nil { + _ = c.Error(err) + return + } + c.JSON(http.StatusOK, req.Config) +} diff --git a/pkg/apiserver/user/sso/service.go b/pkg/apiserver/user/sso/service.go new file mode 100644 index 0000000000..9498b4b37b --- /dev/null +++ b/pkg/apiserver/user/sso/service.go @@ -0,0 +1,440 @@ +// Copyright 2021 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package sso + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io/ioutil" + "net/url" + "os" + "path" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + "github.com/gtank/cryptopasta" + "github.com/joomcode/errorx" + "github.com/pingcap/log" + "go.uber.org/fx" + "go.uber.org/zap" + "golang.org/x/oauth2" + "gorm.io/gorm/clause" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" + "github.com/pingcap/tidb-dashboard/pkg/config" + "github.com/pingcap/tidb-dashboard/pkg/dbstore" + "github.com/pingcap/tidb-dashboard/pkg/tidb" +) + +var ( + ErrNS = errorx.NewNamespace("error.api.user.sso") + ErrUnsupportedUser = ErrNS.NewType("unsupported_user") + ErrInvalidImpersonateCredential = ErrNS.NewType("invalid_impersonate_credential") + ErrDiscoverFailed = ErrNS.NewType("discover_failed") + ErrBadConfig = ErrNS.NewType("bad_config") + ErrOIDCInternalErr = ErrNS.NewType("oidc_internal_err") +) + +const ( + discoveryTimeout = time.Second * 30 + exchangeTimeout = time.Second * 30 + userInfoTimeout = time.Second * 30 +) + +type ServiceParams struct { + fx.In + LocalStore *dbstore.DB + TiDBClient *tidb.Client + ConfigManager *config.DynamicConfigManager +} + +type Service struct { + params ServiceParams + lifecycleCtx context.Context + oauthStateSecret []byte + + encKeyPath string + encKeyLock sync.Mutex +} + +func newService(p ServiceParams, lc fx.Lifecycle, config *config.Config) (*Service, error) { + if err := autoMigrate(p.LocalStore); err != nil { + return nil, err + } + s := &Service{ + params: p, + oauthStateSecret: cryptopasta.NewHMACKey()[:], + encKeyPath: path.Join(config.DataDir, "dbek.bin"), + encKeyLock: sync.Mutex{}, + } + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.lifecycleCtx = ctx + return nil + }, + }) + return s, nil +} + +var Module = fx.Options( + fx.Provide(newService), + fx.Invoke(registerRouter), +) + +func (s *Service) getMasterEncKey() (*[32]byte, error) { + b, err := ioutil.ReadFile(s.encKeyPath) + if err != nil { + // Key does not exist + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(b) != 32 { + return nil, fmt.Errorf("encryption key is broken") + } + + var fixedLenKey [32]byte + copy(fixedLenKey[:], b) + + return &fixedLenKey, nil +} + +// This function is thread-safe. +func (s *Service) getOrCreateMasterEncKey() (*[32]byte, error) { + s.encKeyLock.Lock() + defer s.encKeyLock.Unlock() + + key, _ := s.getMasterEncKey() + if key != nil { + return key, nil + } + + // Try to create a key otherwise + key = cryptopasta.NewEncryptionKey() + err := ioutil.WriteFile(s.encKeyPath, key[:], 0400) // read only for owner + if err != nil { + return nil, fmt.Errorf("persist key failed: %v", err) + } + return key, nil +} + +// getAndDecryptImpersonation reads the impersonation record from local Sqlite and decrypt the record to get the +// plain SQL password. Currently this function only reads `root` user impersonation. +func (s *Service) getAndDecryptImpersonation() (string, string, error) { + var imp SSOImpersonationModel + // TODO: Support different users + err := s.params.LocalStore. + Where("sql_user = ?", "root"). + First(&imp).Error + if err != nil { + return "", "", fmt.Errorf("bad record: %v", err) + } + key, err := s.getMasterEncKey() + if err != nil { + return "", "", fmt.Errorf("bad encryption key: %v", err) + } + if key == nil { + return "", "", fmt.Errorf("encryption key is missing") + } + encrypted, err := hex.DecodeString(imp.EncryptedPass) + if err != nil { + return "", "", fmt.Errorf("bad record: %v", err) + } + decryptedPass, err := cryptopasta.Decrypt(encrypted, key) + if err != nil { + return "", "", fmt.Errorf("bad record: %v", err) + } + return imp.SQLUser, string(decryptedPass), nil +} + +func (s *Service) updateImpersonationStatus(user string, status ImpersonateStatus) error { + return s.params.LocalStore. + Model(&SSOImpersonationModel{}). + Where("sql_user = ?", user). + Update("last_impersonate_status", status). + Error +} + +// newSessionFromImpersonation creates a new session from the impersonation records. +func (s *Service) newSessionFromImpersonation(userInfo *oAuthUserInfo, idToken string) (*utils.SessionUser, error) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + return nil, err + } + + user, password, err := s.getAndDecryptImpersonation() + if err != nil { + return nil, err + } + { + // Try to establish a connection to verify the user and password. + db, err := s.params.TiDBClient.OpenSQLConn(user, password) + if err != nil { + if errorx.IsOfType(err, tidb.ErrTiDBAuthFailed) { + _ = s.updateImpersonationStatus(user, ImpersonateStatusAuthFail) + return nil, ErrInvalidImpersonateCredential.Wrap(err, "Invalid SQL credential") + } + return nil, err + } + + _ = s.updateImpersonationStatus(user, ImpersonateStatusSuccess) + _ = utils.CloseTiDBConnection(db) + } + return &utils.SessionUser{ + Version: utils.SessionVersion, + HasTiDBAuth: true, + TiDBUsername: user, + TiDBPassword: password, + DisplayName: userInfo.Email, + IsShareable: true, + IsWriteable: !dc.SSO.CoreConfig.IsReadOnly, + OIDCIDToken: idToken, + }, nil +} + +func (s *Service) createImpersonation(user string, password string) (*SSOImpersonationModel, error) { + if user != "root" { + return nil, ErrUnsupportedUser.New("User must be root") + } + { + // First try to establish a connection to verify the user and password. + db, err := s.params.TiDBClient.OpenSQLConn(user, password) + if err != nil { + if errorx.IsOfType(err, tidb.ErrTiDBAuthFailed) { + return nil, ErrInvalidImpersonateCredential.Wrap(err, "Invalid SQL credential") + } + return nil, err + } + _ = utils.CloseTiDBConnection(db) + } + key, err := s.getOrCreateMasterEncKey() + if err != nil { + return nil, err + } + encrypted, err := cryptopasta.Encrypt([]byte(password), key) + if err != nil { + return nil, err + } + encryptedInHex := hex.EncodeToString(encrypted) + + record := &SSOImpersonationModel{ + SQLUser: user, + EncryptedPass: encryptedInHex, + LastImpersonateStatus: nil, + } + err = s.params.LocalStore.Clauses(clause.Insert{Modifier: "OR REPLACE"}).Create(&record).Error + if err != nil { + return nil, err + } + return record, nil +} + +func (s *Service) revokeAllImpersonations() error { + return s.params.LocalStore. + Exec(fmt.Sprintf("DELETE FROM `%s`", SSOImpersonationModel{}.TableName())). //nolint:gosec + Error +} + +type oidcWellKnownConfig struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + UserInfoURL string `json:"userinfo_endpoint"` + EndSessionURL string `json:"end_session_endpoint"` +} + +func (s *Service) discoverOIDC(issuer string) (*oidcWellKnownConfig, error) { + if !strings.HasPrefix(issuer, "http://") && !strings.HasPrefix(issuer, "https://") { + issuer = "http://" + issuer + } + _, err := url.Parse(issuer) + if err != nil { + return nil, ErrDiscoverFailed.Wrap(err, "Invalid URL format") + } + + ctx, cancel := context.WithTimeout(s.lifecycleCtx, discoveryTimeout) + defer cancel() + + wellKnownURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + resp, err := resty.New().R().SetContext(ctx).SetResult(&oidcWellKnownConfig{}).Get(wellKnownURL) + if err != nil { + return nil, ErrDiscoverFailed.Wrap(err, "Failed to discover OIDC endpoints") + } + wellKnownConfig := resp.Result().(*oidcWellKnownConfig) + if wellKnownConfig.Issuer != issuer { + return nil, ErrDiscoverFailed.New("Issuer did not match in the OIDC provider, expect %s, got %s", issuer, wellKnownConfig.Issuer) + } + if len(wellKnownConfig.TokenURL) == 0 { + return nil, ErrDiscoverFailed.New("TokenURL is not provided in the OIDC provider") + } + if len(wellKnownConfig.AuthURL) == 0 { + return nil, ErrDiscoverFailed.New("AuthURL is not provided in the OIDC provider") + } + if len(wellKnownConfig.UserInfoURL) == 0 { + return nil, ErrDiscoverFailed.New("UserInfoURL is not provided in the OIDC provider") + } + return wellKnownConfig, nil +} + +func (s *Service) IsEnabled() (bool, error) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + return false, err + } + return dc.SSO.CoreConfig.Enabled, nil +} + +func (s *Service) buildOAuth2Config(redirectURL string) (*oauth2.Config, error) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + return nil, err + } + if !dc.SSO.CoreConfig.Enabled { + return nil, ErrBadConfig.New("SSO is not enabled") + } + return &oauth2.Config{ + ClientID: dc.SSO.CoreConfig.ClientID, + RedirectURL: redirectURL, + Endpoint: oauth2.Endpoint{ + AuthURL: dc.SSO.AuthURL, + TokenURL: dc.SSO.TokenURL, + }, + Scopes: []string{"openid", "profile", "email"}, + }, nil +} + +// buildOAuthURL builds an OAuth URL (to be redirected by the browser) if OIDC SSO is enabled. +// Returns nil if OIDC SSO is not enabled. +// +// `state` is generated by the browser, persisted in local storage and to be verified later before exchange. +// Browser uses this to ensure that the auth callback is not replayed (by an CSRF attacker that use another state). +// +// `codeVerifier` is also generated by the browser, persisted in local storage and will be presented to the RP at exchange. +// RP uses this to ensure that the exchange request is indeed issued by the same client (browser instance). +func (s *Service) buildOAuthURL(redirectURL string, state string, codeVerifier string) (string, error) { + oauthConfig, err := s.buildOAuth2Config(redirectURL) + if err != nil { + return "", err + } + + // generate PKCE code challenge, which is base64(sha256(codeVerifier)). + h := sha256.New() + _, _ = h.Write([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + authURL := oauthConfig.AuthCodeURL(state, + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256")) + return authURL, nil +} + +func (s *Service) exchangeOAuthCode(redirectURL string, code string, codeVerifier string) (string, string, error) { + oauthConfig, err := s.buildOAuth2Config(redirectURL) + if err != nil { + return "", "", err + } + + ctx, cancel := context.WithTimeout(s.lifecycleCtx, exchangeTimeout) + defer cancel() + token, err := oauthConfig.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + if err != nil { + return "", "", ErrOIDCInternalErr.Wrap(err, "oidc: exchange failed") + } + + idToken, ok := token.Extra("id_token").(string) + if !ok { + return "", "", ErrOIDCInternalErr.Wrap(err, "oidc: id_token not exist") + } + + return token.AccessToken, idToken, nil +} + +type oAuthUserInfo struct { + Name string `json:"name"` + Email string `json:"email"` +} + +func (s *Service) oAuthGetUserInfo(accessToken string) (*oAuthUserInfo, error) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + return nil, err + } + if !dc.SSO.CoreConfig.Enabled { + return nil, ErrBadConfig.New("SSO is not enabled") + } + + ctx, cancel := context.WithTimeout(s.lifecycleCtx, userInfoTimeout) + defer cancel() + + resp, err := resty.New().R().SetContext(ctx). + SetResult(&oAuthUserInfo{}). + SetAuthToken(accessToken). + Get(dc.SSO.UserInfoURL) + + if err != nil { + return nil, ErrOIDCInternalErr.Wrap(err, "Failed to read user info") + } + info := resp.Result().(*oAuthUserInfo) + return info, nil +} + +func (s *Service) NewSessionFromOAuthExchange(redirectURL string, code string, codeVerifier string) (*utils.SessionUser, error) { + ak, idToken, err := s.exchangeOAuthCode(redirectURL, code, codeVerifier) + if err != nil { + return nil, ErrBadConfig.Wrap(err, "SSO is not configured correctly") + } + + info, err := s.oAuthGetUserInfo(ak) + if err != nil { + // This is likely not a configuration error + return nil, err + } + + log.Info("New session via SSO", zap.Any("userinfo", info)) + + u, err := s.newSessionFromImpersonation(info, idToken) + if err != nil { + return nil, ErrBadConfig.Wrap(err, "SSO is not configured correctly") + } + return u, nil +} + +func (s *Service) BuildEndSessionURL(user *utils.SessionUser, redirectURL string) (string, error) { + dc, err := s.params.ConfigManager.Get() + if err != nil { + return "", err + } + if !dc.SSO.CoreConfig.Enabled { + return "", ErrBadConfig.New("SSO is not enabled") + } + u, err := url.Parse(dc.SSO.SignOutURL) + if err != nil { + return "", ErrBadConfig.Wrap(err, "Bad end session URL") + } + q := u.Query() + q.Add("client_id", dc.SSO.CoreConfig.ClientID) + q.Add("id_token_hint", user.OIDCIDToken) + if len(redirectURL) > 0 { + q.Add("post_logout_redirect_uri", redirectURL) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} diff --git a/pkg/apiserver/user/sso/ssoauth/auth.go b/pkg/apiserver/user/sso/ssoauth/auth.go new file mode 100644 index 0000000000..91bfd28c33 --- /dev/null +++ b/pkg/apiserver/user/sso/ssoauth/auth.go @@ -0,0 +1,66 @@ +package ssoauth + +import ( + "encoding/json" + + "go.uber.org/fx" + + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/user/sso" + "github.com/pingcap/tidb-dashboard/pkg/apiserver/utils" +) + +const typeID utils.AuthType = 2 + +type Authenticator struct { + user.BaseAuthenticator + ssoService *sso.Service +} + +func newAuthenticator(ssoService *sso.Service) *Authenticator { + return &Authenticator{ + ssoService: ssoService, + } +} + +func registerAuthenticator(a *Authenticator, authService *user.AuthService) { + authService.RegisterAuthenticator(typeID, a) +} + +var Module = fx.Options( + fx.Provide(newAuthenticator), + fx.Invoke(registerAuthenticator), +) + +type SSOExtra struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + RedirectURL string `json:"redirect_url"` +} + +func (a *Authenticator) Authenticate(f user.AuthenticateForm) (*utils.SessionUser, error) { + var extra SSOExtra + err := json.Unmarshal([]byte(f.Extra), &extra) + if err != nil { + return nil, utils.ErrInvalidRequest.Wrap(err, "Invalid extra payload") + } + u, err := a.ssoService.NewSessionFromOAuthExchange(extra.RedirectURL, extra.Code, extra.CodeVerifier) + if err != nil { + return nil, err + } + return u, nil +} + +func (a *Authenticator) IsEnabled() (bool, error) { + return a.ssoService.IsEnabled() +} + +func (a *Authenticator) SignOutInfo(u *utils.SessionUser, redirectURL string) (*user.SignOutInfo, error) { + esURL, err := a.ssoService.BuildEndSessionURL(u, redirectURL) + if err != nil { + return nil, err + } + return &user.SignOutInfo{ + EndSessionURL: esURL, + }, nil +} diff --git a/pkg/apiserver/utils/auth.go b/pkg/apiserver/utils/auth.go index 411f3c0c0b..bacfd6e7e3 100644 --- a/pkg/apiserver/utils/auth.go +++ b/pkg/apiserver/utils/auth.go @@ -14,95 +14,50 @@ package utils import ( - "encoding/hex" "time" - "github.com/gtank/cryptopasta" - "github.com/vmihailenco/msgpack/v5" + "github.com/gin-gonic/gin" ) +type AuthType int + +const SessionVersion = 2 + +// The content of this structure will be encrypted and stored as both Session Token and Sharing Token. +// For fields that don't need to be cloned during session sharing, mark fields as `msgpack:"-"`. type SessionUser struct { + // Must be 2. This field is used to invalidate outdated sessions after schema change. + Version int + + DisplayName string + HasTiDBAuth bool TiDBUsername string TiDBPassword string - // Whether this session is shared, i.e. built from another existing session. - // For security consideration, we do not allow shared session to be shared again - // since sharing can extend session lifetime. - IsShared bool `msgpack:"-"` - SharedSessionExpireAt time.Time `msgpack:"-"` + // This field only exists for CodeAuth. + SharedSessionExpireAt time.Time `msgpack:"-" json:",omitempty"` + + // This field only exists for SSOAuth + OIDCIDToken string `json:",omitempty"` - // TODO: Add privilege table fields + // These fields should not be updated by individual authenticators. + AuthFrom AuthType `msgpack:"-" json:",omitempty"` + + // TODO: Make them table fields + IsShareable bool + IsWriteable bool } const ( // The key that attached the SessionUser in the gin Context. SessionUserKey = "user" - - // Max permitted lifetime of a shared session. - MaxSessionShareExpiry = time.Hour * 24 * 30 ) -// The secret is always regenerated each time starting TiDB Dashboard. -var sharingCodeSecret = cryptopasta.NewEncryptionKey() - -type sharedSession struct { - Session *SessionUser - ExpireAt time.Time -} - -func (session *SessionUser) ToSharingCode(expireIn time.Duration) *string { - if session.IsShared { - return nil - } - if expireIn < 0 { - return nil - } - if expireIn > MaxSessionShareExpiry { - return nil - } - - shared := sharedSession{ - Session: session, - ExpireAt: time.Now().Add(expireIn), - } - - b, err := msgpack.Marshal(&shared) - if err != nil { - // Do not output anything about how serialization is failed to avoid potential leaks. +func GetSession(c *gin.Context) *SessionUser { + i, ok := c.Get(SessionUserKey) + if !ok { return nil } - - encrypted, err := cryptopasta.Encrypt(b, sharingCodeSecret) - if err != nil { - return nil - } - - codeInHex := hex.EncodeToString(encrypted) - return &codeInHex -} - -func NewSessionFromSharingCode(codeInHex string) *SessionUser { - encrypted, err := hex.DecodeString(codeInHex) - if err != nil { - return nil - } - - b, err := cryptopasta.Decrypt(encrypted, sharingCodeSecret) - if err != nil { - return nil - } - - var shared sharedSession - if err := msgpack.Unmarshal(b, &shared); err != nil { - return nil - } - - if time.Now().After(shared.ExpireAt) { - return nil - } - - shared.Session.IsShared = true - shared.Session.SharedSessionExpireAt = shared.ExpireAt - return shared.Session + return i.(*SessionUser) } diff --git a/pkg/apiserver/utils/error.go b/pkg/apiserver/utils/error.go index 0c9ae4b018..dd3756d6f3 100644 --- a/pkg/apiserver/utils/error.go +++ b/pkg/apiserver/utils/error.go @@ -29,14 +29,14 @@ var ( var ErrUnauthorized = ErrNS.NewType("unauthorized") func MakeUnauthorizedError(c *gin.Context) { - _ = c.Error(ErrUnauthorized.NewWithNoMessage()) + _ = c.Error(ErrUnauthorized.New("Sign in is required")) c.Status(http.StatusUnauthorized) } var ErrInsufficientPrivilege = ErrNS.NewType("insufficient_privilege") func MakeInsufficientPrivilegeError(c *gin.Context) { - _ = c.Error(ErrInsufficientPrivilege.NewWithNoMessage()) + _ = c.Error(ErrInsufficientPrivilege.New("Insufficient privilege")) c.Status(http.StatusForbidden) } diff --git a/pkg/apiserver/utils/tidb_conn.go b/pkg/apiserver/utils/tidb_conn.go index d71217acbc..4255d5a9f9 100644 --- a/pkg/apiserver/utils/tidb_conn.go +++ b/pkg/apiserver/utils/tidb_conn.go @@ -35,7 +35,7 @@ const ( // This middleware must be placed after the `MWAuthRequired()` middleware, otherwise it will panic. func MWConnectTiDB(tidbClient *tidb.Client) gin.HandlerFunc { return func(c *gin.Context) { - sessionUser := c.MustGet(SessionUserKey).(*SessionUser) + sessionUser := GetSession(c) if sessionUser == nil { panic("invalid sessionUser") } diff --git a/pkg/config/dynamic_config.go b/pkg/config/dynamic_config.go index 2added6ba4..cea9a07aba 100644 --- a/pkg/config/dynamic_config.go +++ b/pkg/config/dynamic_config.go @@ -55,9 +55,25 @@ type ProfilingConfig struct { AutoCollectionIntervalSecs uint `json:"auto_collection_interval_secs"` } +type SSOCoreConfig struct { + Enabled bool `json:"enabled"` + ClientID string `json:"client_id"` + DiscoveryURL string `json:"discovery_url"` + IsReadOnly bool `json:"is_read_only"` +} + +type SSOConfig struct { + CoreConfig SSOCoreConfig `json:"core_config"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + UserInfoURL string `json:"user_info_url"` + SignOutURL string `json:"sign_out_url"` +} + type DynamicConfig struct { KeyVisual KeyVisualConfig `json:"keyvisual"` Profiling ProfilingConfig `json:"profiling"` + SSO SSOConfig `json:"sso"` } func (c *DynamicConfig) Clone() *DynamicConfig { diff --git a/pkg/config/dynamic_config_manager.go b/pkg/config/dynamic_config_manager.go index 1c935e172d..22792d7b7c 100644 --- a/pkg/config/dynamic_config_manager.go +++ b/pkg/config/dynamic_config_manager.go @@ -182,7 +182,7 @@ func (m *DynamicConfigManager) load() (*DynamicConfig, error) { } return &dc, nil default: - log.Error("UNREACHABLE") + log.Error("etcd is unreachable") return nil, backoff.Permanent(ErrUnableToLoad.New("unreachable")) } } diff --git a/pkg/keyvisual/service.go b/pkg/keyvisual/service.go index b08d9f15b5..5449876090 100644 --- a/pkg/keyvisual/service.go +++ b/pkg/keyvisual/service.go @@ -118,7 +118,7 @@ func RegisterRouter(r *gin.RouterGroup, auth *user.AuthService, s *Service) { endpoint.Use(auth.MWAuthRequired()) endpoint.GET("/config", s.getDynamicConfig) - endpoint.PUT("/config", s.setDynamicConfig) + endpoint.PUT("/config", auth.MWRequireWritePriv(), s.setDynamicConfig) endpoint.Use(s.status.MWHandleStopped(stoppedHandler)) endpoint.GET("/heatmaps", s.heatmaps) diff --git a/release-version b/release-version index 6294d37572..31babf94a1 100644 --- a/release-version +++ b/release-version @@ -1,3 +1,3 @@ # This file specifies the TiDB Dashboard internal version, which will be printed in `--version` # and UI. In release branch, changing this file will result in publishing a new version and tag. -2021.07.09.1 +2021.07.17.1 diff --git a/ui/dashboardApp/index.ts b/ui/dashboardApp/index.ts index 4a6deb6300..4a03ab7509 100644 --- a/ui/dashboardApp/index.ts +++ b/ui/dashboardApp/index.ts @@ -15,7 +15,7 @@ import { initSentryRoutingInstrument, applySentryTracingInterceptor, } from '@lib/utils/sentryHelpers' -import client, { ErrorStrategy, InfoInfoResponse } from '@lib/client' +import client, { InfoInfoResponse } from '@lib/client' import LayoutMain from '@dashboard/layout/main' import LayoutSignIn from '@dashboard/layout/signin' @@ -32,6 +32,8 @@ import AppInstanceProfiling from '@lib/apps/InstanceProfiling/index.meta' import AppQueryEditor from '@lib/apps/QueryEditor/index.meta' import AppConfiguration from '@lib/apps/Configuration/index.meta' import AppDebugAPI from '@lib/apps/DebugAPI/index.meta' +import { handleSSOCallback, isSSOCallback } from '@lib/utils/authSSO' +import { mustLoadAppInfo, reloadWhoAmI } from '@lib/utils/store' // import __APP_NAME__ from '@lib/apps/__APP_NAME__/index.meta' // NOTE: Don't remove above comment line, it is a placeholder for code generator @@ -42,7 +44,7 @@ function removeSpinner() { } } -async function main() { +async function webPageStart() { const options = loadAppOptions() if (options.lang) { i18next.changeLanguage(options.lang) @@ -54,10 +56,7 @@ async function main() { let info: InfoInfoResponse try { - const i = await client.getInstance().infoGet({ - errorStrategy: ErrorStrategy.Custom, - }) - info = i.data + info = await mustLoadAppInfo() } catch (e) { Modal.error({ title: 'Failed to connect to TiDB Dashboard server', @@ -121,8 +120,14 @@ async function main() { // .register(__APP_NAME__) // NOTE: Don't remove above comment line, it is a placeholder for code generator - if (routing.isLocationMatch('/')) { - singleSpa.navigateToUrl('#' + registry.getDefaultRouter()) + try { + await reloadWhoAmI() + + if (routing.isLocationMatch('/')) { + singleSpa.navigateToUrl('#' + registry.getDefaultRouter()) + } + } catch (e) { + // If there are auth errors, redirection will happen any way. So we continue. } window.addEventListener('single-spa:app-change', () => { @@ -140,21 +145,29 @@ async function main() { singleSpa.start() } -///////////////////////////////////// - -if (routing.isPortalPage()) { - // the portal page is only used to receive options - window.addEventListener( - 'message', - (event) => { - const { token, lang, hideNav, redirectPath } = event.data - auth.setAuthToken(token) - saveAppOptions({ hideNav, lang }) - window.location.hash = `#${redirectPath}` - window.location.reload() - }, - { once: true } - ) -} else { - main() +async function main() { + if (routing.isPortalPage()) { + // the portal page is only used to receive options + window.addEventListener( + 'message', + (event) => { + const { token, lang, hideNav, redirectPath } = event.data + auth.setAuthToken(token) + saveAppOptions({ hideNav, lang }) + window.location.hash = `#${redirectPath}` + window.location.reload() + }, + { once: true } + ) + return + } + + if (isSSOCallback()) { + await handleSSOCallback() + return + } + + await webPageStart() } + +main() diff --git a/ui/dashboardApp/layout/main/Sider/Banner.tsx b/ui/dashboardApp/layout/main/Sider/Banner.tsx index 675c336207..cd63809980 100644 --- a/ui/dashboardApp/layout/main/Sider/Banner.tsx +++ b/ui/dashboardApp/layout/main/Sider/Banner.tsx @@ -3,11 +3,11 @@ import { MenuUnfoldOutlined, MenuFoldOutlined } from '@ant-design/icons' import { useSize } from 'ahooks' import Flexbox from '@g07cha/flexbox-react' import { useSpring, animated } from 'react-spring' -import { useClientRequest } from '@lib/utils/useClientRequest' -import client, { InfoInfoResponse } from '@lib/client' +import { InfoInfoResponse } from '@lib/client' import { ReactComponent as Logo } from './logo-icon-light.svg' import styles from './Banner.module.less' +import { store } from '@lib/utils/store' const toggleWidth = 40 const toggleHeight = 50 @@ -56,16 +56,14 @@ export default function ToggleBanner({ width: collapsed ? collapsedWidth : toggleWidth, }) - const { data, isLoading } = useClientRequest((reqConfig) => - client.getInstance().infoGet(reqConfig) - ) + const appInfo = store.useState((s) => s.appInfo) const version = useMemo(() => { - if (data) { - return parseVersion(data) + if (appInfo) { + return parseVersion(appInfo) } return null - }, [data]) + }, [appInfo]) return (
@@ -85,8 +83,7 @@ export default function ToggleBanner({
TiDB Dashboard
- {isLoading && '...'} - {!isLoading && (version || 'Version unknown')} + {version || 'Version unknown'}
diff --git a/ui/dashboardApp/layout/main/Sider/index.tsx b/ui/dashboardApp/layout/main/Sider/index.tsx index 49940c4c69..a66cc58a5d 100644 --- a/ui/dashboardApp/layout/main/Sider/index.tsx +++ b/ui/dashboardApp/layout/main/Sider/index.tsx @@ -5,11 +5,9 @@ import { Link } from 'react-router-dom' import { useEventListener } from 'ahooks' import { useTranslation } from 'react-i18next' import { useSpring, animated } from 'react-spring' -import client from '@lib/client' - import Banner from './Banner' import styles from './index.module.less' -import { useClientRequest } from '@lib/utils/useClientRequest' +import { store } from '@lib/utils/store' function useAppMenuItem(registry, appId, title?: string) { const { t } = useTranslation() @@ -50,12 +48,8 @@ function Sider({ const { t } = useTranslation() const activeAppId = useActiveAppId(registry) - const { data: currentLogin } = useClientRequest((reqConfig) => - client.getInstance().infoWhoami(reqConfig) - ) - const { data: info } = useClientRequest((reqConfig) => - client.getInstance().infoGet(reqConfig) - ) + const whoAmI = store.useState((s) => s.whoAmI) + const appInfo = store.useState((s) => s.appInfo) const debugSubMenuItems = [ useAppMenuItem(registry, 'instance_profiling'), @@ -106,14 +100,11 @@ function Sider({ debugSubMenu, ] - if (info?.enable_experimental) { + if (appInfo?.enable_experimental) { menuItems.push(experimentalSubMenu) } - let displayName = currentLogin?.username ?? '...' - if (currentLogin?.is_shared) { - displayName += ' (Shared)' - } + let displayName = whoAmI?.display_name || '...' const extraMenuItems = [ useAppMenuItem(registry, 'dashboard_settings'), diff --git a/ui/dashboardApp/layout/signin/index.tsx b/ui/dashboardApp/layout/signin/index.tsx index 074f6be1d9..0b82e8421f 100644 --- a/ui/dashboardApp/layout/signin/index.tsx +++ b/ui/dashboardApp/layout/signin/index.tsx @@ -12,7 +12,7 @@ import { ArrowRightOutlined, CloseOutlined, } from '@ant-design/icons' -import { Form, Input, Button, message, Typography } from 'antd' +import { Form, Input, Button, message, Typography, Modal } from 'antd' import { useTranslation } from 'react-i18next' import LanguageDropdown from '@lib/components/LanguageDropdown' import client, { ErrorStrategy, UserAuthenticateForm } from '@lib/client' @@ -20,13 +20,17 @@ import * as auth from '@lib/utils/auth' import { useMount } from 'react-use' import Flexbox from '@g07cha/flexbox-react' import { usePersistFn } from 'ahooks' - import { ReactComponent as Logo } from './logo.svg' import styles from './index.module.less' +import { useEffect } from 'react' +import { getAuthURL } from '@lib/utils/authSSO' +import { AuthTypes } from '@lib/utils/auth' enum DisplayFormType { + uninitialized, tidbCredential, shareCode, + sso, } function AlternativeAuthLink({ onClick }) { @@ -84,6 +88,7 @@ function AlternativeAuthForm({ className, onClose, onSwitchForm, + supportedAuthTypes, ...restProps }) { const { t } = useTranslation() @@ -124,6 +129,15 @@ function AlternativeAuthForm({ onClick={() => onSwitchForm(DisplayFormType.shareCode)} /> + {Boolean(supportedAuthTypes.indexOf(AuthTypes.SSO) > -1) && ( + + onSwitchForm(DisplayFormType.sso)} + /> + + )}
@@ -179,7 +193,7 @@ function TiDBSignInForm({ successRoute, onClickAlternative }) { (form) => ({ username: form.username, password: form.password, - type: 0, + type: AuthTypes.SQLUser, }), () => { refForm.setFieldsValue({ password: '' }) @@ -263,7 +277,7 @@ function CodeSignInForm({ successRoute, onClickAlternative }) { successRoute, (form) => ({ password: form.code, - type: 1, + type: AuthTypes.SharingCode, }), () => { refForm.setFieldsValue({ code: '' }) @@ -322,12 +336,56 @@ function CodeSignInForm({ successRoute, onClickAlternative }) { ) } +function SSOSignInForm({ successRoute, onClickAlternative }) { + const { t } = useTranslation() + const [isLoading, setIsLoading] = useState(false) + + const handleSignIn = useCallback(async () => { + setIsLoading(true) + try { + const url = await getAuthURL() + window.location.href = url + // Do not hide loading status when url is resolved, since we are now jumping + } catch (e) { + setIsLoading(false) + } + }, []) + + return ( +
+
+
+ + + + + + + +
+
+ ) +} + function App({ registry }) { const successRoute = useMemo(() => `#${registry.getDefaultRouter()}`, [ registry, ]) const [alternativeVisible, setAlternativeVisible] = useState(false) - const [formType, setFormType] = useState(DisplayFormType.tidbCredential) + const [formType, setFormType] = useState(DisplayFormType.uninitialized) + const [supportedAuthTypes, setSupportedAuthTypes] = useState>([ + 0, + ]) const handleClickAlternative = useCallback(() => { setAlternativeVisible(true) @@ -342,6 +400,31 @@ function App({ registry }) { setAlternativeVisible(false) }, []) + useEffect(() => { + async function run() { + try { + const resp = await client.getInstance().userGetLoginInfo() + const loginInfo = resp.data + if ( + (loginInfo.supported_auth_types?.indexOf(AuthTypes.SSO) ?? -1) > -1 + ) { + setFormType(DisplayFormType.sso) + } else { + setFormType(DisplayFormType.tidbCredential) + } + setSupportedAuthTypes(loginInfo.supported_auth_types ?? []) + } catch (e) { + Modal.error({ + title: 'Initialize Sign in failed', + content: '' + e, + okText: 'Reload', + onOk: () => window.location.reload(), + }) + } + } + run() + }, []) + return (
@@ -356,6 +439,7 @@ function App({ registry }) { className={className} onClose={handleAlternativeClose} onSwitchForm={handleSwitchForm} + supportedAuthTypes={supportedAuthTypes} /> )} @@ -371,6 +455,12 @@ function App({ registry }) { onClickAlternative={handleClickAlternative} /> )} + {formType === DisplayFormType.sso && ( + + )} - + - {policyOptions} + + {policyOptions} + @@ -180,7 +185,12 @@ function KeyVizSettingForm({ onClose, onConfigUpdated }: Props) { - + + + + )} + + + ) +} diff --git a/ui/lib/apps/UserProfile/Form.SSO.tsx b/ui/lib/apps/UserProfile/Form.SSO.tsx new file mode 100644 index 0000000000..6d5cebd288 --- /dev/null +++ b/ui/lib/apps/UserProfile/Form.SSO.tsx @@ -0,0 +1,316 @@ +import { CheckCircleFilled } from '@ant-design/icons' +import client, { SsoSSOImpersonationModel } from '@lib/client' +import { AnimatedSkeleton, ErrorBar } from '@lib/components' +import { useIsWriteable } from '@lib/utils/store' +import { useClientRequest } from '@lib/utils/useClientRequest' +import { + Alert, + Button, + Checkbox, + Form, + Input, + Modal, + Space, + Switch, + Typography, +} from 'antd' +import React from 'react' +import { useEffect } from 'react' +import { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DEFAULT_FORM_ITEM_STYLE } from './constants' + +interface IUserAuthInputProps { + value?: SsoSSOImpersonationModel + onChange?: (value: SsoSSOImpersonationModel) => void +} + +function isImpersonationNotFailed(imp?: SsoSSOImpersonationModel) { + return Boolean(imp && imp.last_impersonate_status !== 'auth_fail') +} + +function UserAuthInput({ value, onChange }: IUserAuthInputProps) { + const { t } = useTranslation() + const [modalVisible, setModalVisible] = useState(false) + const [isPosting, setIsPosting] = useState(false) + const isWriteable = useIsWriteable() + const handleClose = useCallback(() => { + setModalVisible(false) + }, []) + + const handleAuthnClick = useCallback(() => { + setModalVisible(true) + }, []) + + const handleFinish = useCallback( + async (data) => { + setIsPosting(true) + try { + const resp = await client.getInstance().userSSOCreateImpersonation({ + sql_user: data.user, + password: data.password, + }) + setModalVisible(false) + onChange?.(resp.data) + } finally { + setIsPosting(false) + } + }, + [onChange] + ) + + return ( + <> + {Boolean(!value) && ( + + + + + )} + {isImpersonationNotFailed(value) && ( + + + + {' '} + {t('user_profile.sso.form.user.authn_status.ok')} + + + )} + {Boolean(value && !isImpersonationNotFailed(value)) && ( + + + + {' '} + {t('user_profile.sso.form.user.authn_status.auth_failed')} + + + + )} + +
+ + + + + + + + + + + + + + + +
+
+ + ) +} + +const UserAuthInputMemo = React.memo(UserAuthInput) + +export function SSOForm() { + const { t } = useTranslation() + const [isChanged, setIsChanged] = useState(false) + const [isPosting, setIsPosting] = useState(false) + const handleValuesChange = useCallback(() => setIsChanged(true), []) + const [form] = Form.useForm() + const { + error, + isLoading, + data: config, + sendRequest, + } = useClientRequest((reqConfig) => + client.getInstance().userSSOGetConfig(reqConfig) + ) + const { + error: impError, + isLoading: impIsLoading, + data: impData, + sendRequest: impSendRequest, + } = useClientRequest((reqConfig) => + client.getInstance().userSSOListImpersonations(reqConfig) + ) + const initialForm = useRef(null) // Used for "Cancel" behaviour + const isWriteable = useIsWriteable() + + useEffect(() => { + if (config) { + form.setFieldsValue(config) + initialForm.current = { ...config } + } + // ignore form + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]) + + useEffect(() => { + if (impData) { + let rootImp: SsoSSOImpersonationModel | undefined = undefined + for (const imp of impData) { + if (imp.sql_user === 'root') { + rootImp = imp + break + } + } + const update = { user_authenticated: rootImp } + form.setFieldsValue(update) + initialForm.current = { + ...initialForm.current, + ...update, + } + } + // ignore form + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [impData]) + + // TODO: Extract common logic + const handleCancel = useCallback(() => { + form.setFieldsValue({ ...initialForm.current }) + setIsChanged(false) + }, [form]) + + const handleFinish = useCallback( + async (data) => { + setIsPosting(true) + try { + await client.getInstance().userSSOSetConfig({ config: data }) + sendRequest() + setIsChanged(false) + } finally { + setIsPosting(false) + } + }, + [sendRequest] + ) + + const handleAuthStateChange = useCallback(() => { + impSendRequest() + }, [impSendRequest]) + + return ( +
+ + {(error || impError) && } + + + + + {(f) => + f.getFieldValue('enabled') && ( + <> + + + + + + + + + + + + + + ) + } + + {isChanged && ( + + + + + + + )} + +
+ ) +} diff --git a/ui/lib/apps/UserProfile/Form.Session.tsx b/ui/lib/apps/UserProfile/Form.Session.tsx new file mode 100644 index 0000000000..cc5d5c8a92 --- /dev/null +++ b/ui/lib/apps/UserProfile/Form.Session.tsx @@ -0,0 +1,221 @@ +import { CopyToClipboard } from 'react-copy-to-clipboard' +import { + CheckOutlined, + CopyOutlined, + LogoutOutlined, + QuestionCircleOutlined, + ShareAltOutlined, +} from '@ant-design/icons' +import client from '@lib/client' +import { + Alert, + Button, + Divider, + Form, + Modal, + Select, + Space, + Tooltip, +} from 'antd' +import React from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Pre } from '@lib/components' +import { getValueFormat } from '@baurine/grafana-value-formats' +import * as auth from '@lib/utils/auth' +import ReactMarkdown from 'react-markdown' +import Checkbox from 'antd/lib/checkbox/Checkbox' +import { store } from '@lib/utils/store' + +const SHARE_SESSION_EXPIRY_HOURS = [ + 0.25, + 0.5, + 1, + 2, + 3, + 6, + 12, + 24, + 24 * 3, + 24 * 7, + 24 * 30, +] + +function ShareSessionButton() { + const { t } = useTranslation() + const [visible, setVisible] = useState(false) + const [isPosting, setIsPosting] = useState(false) + const [code, setCode] = useState(undefined) + const [isCopied, setIsCopied] = useState(false) + const whoAmI = store.useState((s) => s.whoAmI) + + const handleOpen = useCallback(() => { + setVisible(true) + }, []) + + const handleClose = useCallback(() => { + setVisible(false) + setCode(undefined) + setIsPosting(false) + setIsCopied(false) + }, []) + + const handleFinish = useCallback(async (values) => { + try { + setIsPosting(true) + const r = await client.getInstance().userShareSession({ + expire_in_sec: values.expire * 60 * 60, + revoke_write_priv: !!values.read_only, + }) + setCode(r.data.code) + } finally { + setIsPosting(false) + } + }, []) + + const handleCopy = useCallback(() => { + setIsCopied(true) + }, []) + + let button = ( + + ) + + if (whoAmI && !whoAmI.is_shareable) { + button = ( + + {button} + + ) + } + + return ( + <> + {button} + + + + + + + } + visible={!!code} + > + {code}} + type="success" + showIcon + /> + + + + + +
+ + + + + + + + + + + + +
+
+ + ) +} + +export function SessionForm() { + const { t } = useTranslation() + + const handleLogout = useCallback(async () => { + let signOutURL: string | undefined = undefined + try { + const resp = await client + .getInstance() + .userGetSignOutInfo( + `${window.location.protocol}//${window.location.host}${window.location.pathname}` + ) + signOutURL = resp.data.end_session_url + } catch (e) { + console.error(e) + } + + auth.clearAuthToken() + if (signOutURL) { + window.location.href = signOutURL + } else { + window.location.reload() + } + }, []) + + return ( + + + + + ) +} diff --git a/ui/lib/apps/UserProfile/Form.Version.tsx b/ui/lib/apps/UserProfile/Form.Version.tsx new file mode 100644 index 0000000000..f075dcf8c2 --- /dev/null +++ b/ui/lib/apps/UserProfile/Form.Version.tsx @@ -0,0 +1,66 @@ +import { CopyLink, Descriptions, TextWithInfo } from '@lib/components' +import { store } from '@lib/utils/store' +import { Space } from 'antd' +import React from 'react' + +export function VersionForm() { + const info = store.useState((s) => s.appInfo) + + return ( + <> + {Boolean(info) && ( + + + + + + } + > + {info!.version?.internal_version} + + + + + + } + > + {info!.version?.build_git_hash} + + + } + > + {info!.version?.build_time} + + + } + > + {info!.version?.standalone} + + + + + + } + > + {info!.version?.pd_version} + + + )} + + ) +} diff --git a/ui/lib/apps/UserProfile/constants.tsx b/ui/lib/apps/UserProfile/constants.tsx new file mode 100644 index 0000000000..2afed22bb4 --- /dev/null +++ b/ui/lib/apps/UserProfile/constants.tsx @@ -0,0 +1 @@ +export const DEFAULT_FORM_ITEM_STYLE = { width: 200 } diff --git a/ui/lib/apps/UserProfile/index.tsx b/ui/lib/apps/UserProfile/index.tsx index 07d9d525ff..d9773715a4 100644 --- a/ui/lib/apps/UserProfile/index.tsx +++ b/ui/lib/apps/UserProfile/index.tsx @@ -1,439 +1,32 @@ -import { - Button, - Form, - Select, - Space, - Modal, - Alert, - Divider, - Tooltip, - Radio, - Input, - Typography, -} from 'antd' -import React, { useCallback, useState, useEffect, useRef } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' -import { CopyToClipboard } from 'react-copy-to-clipboard' import { HashRouter as Router } from 'react-router-dom' -import { - LogoutOutlined, - ShareAltOutlined, - CopyOutlined, - CheckOutlined, - QuestionCircleOutlined, -} from '@ant-design/icons' -import { - Card, - Root, - AnimatedSkeleton, - Descriptions, - CopyLink, - TextWithInfo, - Pre, - ErrorBar, - Blink, -} from '@lib/components' -import * as auth from '@lib/utils/auth' -import { ALL_LANGUAGES } from '@lib/utils/i18n' -import _ from 'lodash' -import { useClientRequest } from '@lib/utils/useClientRequest' -import client from '@lib/client' -import { getValueFormat } from '@baurine/grafana-value-formats' -import ReactMarkdown from 'react-markdown' - -const DEFAULT_FORM_ITEM_STYLE = { width: 200 } -const SHARE_SESSION_EXPIRY_HOURS = [ - 0.25, - 0.5, - 1, - 2, - 3, - 6, - 12, - 24, - 24 * 3, - 24 * 7, - 24 * 30, -] - -function ShareSessionButton() { - const { t } = useTranslation() - const [visible, setVisible] = useState(false) - const [isPosting, setIsPosting] = useState(false) - const [code, setCode] = useState(undefined) - const [isCopied, setIsCopied] = useState(false) - - const { data } = useClientRequest((reqConfig) => - client.getInstance().infoWhoami(reqConfig) - ) - - const handleOpen = useCallback(() => { - setVisible(true) - }, []) - - const handleClose = useCallback(() => { - setVisible(false) - setCode(undefined) - setIsPosting(false) - setIsCopied(false) - }, []) - - const handleFinish = useCallback(async (values) => { - try { - setIsPosting(true) - const r = await client.getInstance().userShareSession({ - expire_in_sec: values.expire * 60 * 60, - }) - setCode(r.data.code) - } finally { - setIsPosting(false) - } - }, []) - - const handleCopy = useCallback(() => { - setIsCopied(true) - }, []) - - let button = ( - - ) - - if (data?.is_shared) { - button = ( - - {button} - - ) - } - - return ( - <> - {button} - - - - - - - } - visible={!!code} - > - {code}} - type="success" - showIcon - /> - - - {t('user_profile.share_session.close')} - - } - onCancel={handleClose} - width={600} - > - - - -
- - - - - - -
-
- - ) -} - -function PrometheusAddressForm() { - const { t } = useTranslation() - const [isChanged, setIsChanged] = useState(false) - const [isPosting, setIsPosting] = useState(false) - const handleValuesChange = useCallback(() => setIsChanged(true), []) - const { error, isLoading, data } = useClientRequest((reqConfig) => - client.getInstance().metricsGetPromAddress(reqConfig) - ) - const isInitialLoad = useRef(true) - const initialForm = useRef(null) // Used for "Cancel" behaviour - const [form] = Form.useForm() - - useEffect(() => { - if (data && isInitialLoad.current) { - isInitialLoad.current = false - form.setFieldsValue({ - sourceType: - (data.customized_addr?.length ?? 0) > 0 ? 'custom' : 'deployment', - customAddr: data.customized_addr, - }) - initialForm.current = { ...form.getFieldsValue() } - } - }, [data, form]) - - const handleFinish = useCallback( - async (values) => { - let address = '' - if (values.sourceType === 'custom') { - address = values.customAddr || '' - } - try { - setIsPosting(true) - const resp = await client.getInstance().metricsSetCustomPromAddress({ - address, - }) - const customAddr = resp?.data?.normalized_address ?? '' - form.setFieldsValue({ customAddr }) - initialForm.current = { ...form.getFieldsValue() } - setIsChanged(false) - } finally { - setIsPosting(false) - } - }, - [form] - ) - - const handleCancel = useCallback(() => { - form.setFieldsValue({ ...initialForm.current }) - setIsChanged(false) - }, [form]) - - return ( - -
- - - - - {error && } - - - - {t( - 'user_profile.service_endpoints.prometheus.form.deployed' - )} - - - {(data?.deployed_addr?.length ?? 0) > 0 && - `(${data!.deployed_addr})`} - {data && data.deployed_addr?.length === 0 && ( - - ( - {t( - 'user_profile.service_endpoints.prometheus.form.not_deployed' - )} - ) - - )} - - - - - {t('user_profile.service_endpoints.prometheus.form.custom')} - - - - - - - {(f) => - f.getFieldValue('sourceType') === 'custom' && ( - - - - ) - } - - {isChanged && ( - - - - - - - )} -
-
- ) -} +import { Card, Root } from '@lib/components' +import { SSOForm } from './Form.SSO' +import { SessionForm } from './Form.Session' +import { PrometheusAddressForm } from './Form.PrometheusAddr' +import { VersionForm } from './Form.Version' +import { LanguageForm } from './Form.Language' function App() { - const { t, i18n } = useTranslation() - - const handleLanguageChange = useCallback( - (langKey) => { - i18n.changeLanguage(langKey) - }, - [i18n] - ) - - const handleLogout = useCallback(() => { - auth.clearAuthToken() - window.location.reload() - }, []) - - const { data: info, isLoading, error } = useClientRequest((reqConfig) => - client.getInstance().infoGet(reqConfig) - ) - + const { t } = useTranslation() return ( - - - - + + + + -
- - - -
+
- - {error && } - {info && ( - - - - - - } - > - {info.version?.internal_version} - - - - - - } - > - {info.version?.build_git_hash} - - - } - > - {info.version?.build_time} - - - } - > - {info.version?.standalone} - - - - - - } - > - {info.version?.pd_version} - - - )} - +
diff --git a/ui/lib/apps/UserProfile/translations/en.yaml b/ui/lib/apps/UserProfile/translations/en.yaml index efb6bd94ab..7587692fd6 100644 --- a/ui/lib/apps/UserProfile/translations/en.yaml +++ b/ui/lib/apps/UserProfile/translations/en.yaml @@ -1,4 +1,30 @@ user_profile: + sso: + title: Single Sign-On (SSO) + switch: + label: Enable to use SSO when sign into TiDB Dashboard + extra: OIDC based SSO is supported + form: + client_id: OIDC Client ID + discovery_url: OIDC Discovery URL + is_read_only: Sign in as read-only privilege + user: + label: Impersonate SQL User + extra: The SSO signed-in user will be using TiDB Dashboard on behalf of this SQL user and shares its permissions. + must_auth: You must authorize to continue + authn_button: Authorize Impersonation + authn_dialog: + title: Authorize Impersonation + user: SQL User to Impersonate + password: SQL User Password + info: The password of the SQL user will be stored encrypted. The impersonation will fail after SQL user changes the password. + submit: Authorize and Save + close: Cancel + authn_status: + ok: Authorized + auth_failed: 'Cannot impersonate: SQL user password is changed.' + update: Update + cancel: Cancel service_endpoints: title: Service Endpoints prometheus: @@ -18,7 +44,7 @@ user_profile: title: Session sign_out: Sign Out share: Share Current Session - share_unavailable_tooltip: Current session is a shared session and it cannot be shared again + share_unavailable_tooltip: Current session is not allowed to be shared share_session: text: > You can invite others to access this TiDB Dashboard by sharing your @@ -34,6 +60,7 @@ user_profile: Keep the Authorization Code safe! form: expire: Expire in + read_only: Share as read-only privilege submit: Generate Authorization Code close: Close success_dialog: diff --git a/ui/lib/apps/UserProfile/translations/zh.yaml b/ui/lib/apps/UserProfile/translations/zh.yaml index 951f967c97..a0b943afb6 100644 --- a/ui/lib/apps/UserProfile/translations/zh.yaml +++ b/ui/lib/apps/UserProfile/translations/zh.yaml @@ -1,4 +1,30 @@ user_profile: + sso: + title: 单点登录 (SSO) + switch: + label: 允许使用 SSO 登录到 TiDB Dashboard + extra: 支持基于 OIDC 的 SSO 登录 + form: + client_id: OIDC Client ID + discovery_url: OIDC Discovery URL + is_read_only: 以只读权限登录 + user: + label: 实际登录 SQL 用户 + extra: SSO 登录成功后将被视为使用该 SQL 用户登录使用 TiDB Dashboard,并具有该用户对应的操作权限。 + must_auth: 必须完成授权后才能继续 + authn_button: 授权登录为该用户 + authn_dialog: + title: SSO 登录授权 + user: 实际被登录的 SQL 用户 + password: SQL 用户的登录密码 + info: 登录密码将被加密存储;在 SQL 用户修改密码后 SSO 登录将失败(可重新进行登录授权)。 + submit: 授权并保存 + close: 取消 + authn_status: + ok: 已授权 + auth_failed: 授权失败:SQL 用户密码已变更 + update: 更新 + cancel: 取消 service_endpoints: title: 服务端点 prometheus: @@ -18,7 +44,7 @@ user_profile: title: 会话 sign_out: 登出 share: 分享当前会话 - share_unavailable_tooltip: 当前会话是一个被分享的会话,因此无法再次分享 + share_unavailable_tooltip: 当前会话被禁止分享 share_session: text: > 您可以生成一个**授权码**来将您当前的会话分享给其他人、邀请他们使用该 TiDB Dashboard: @@ -32,6 +58,7 @@ user_profile: 警告:已分享的会话无法被提前注销,将保持有效直到有效时间过期,因此请妥善保管授权码。 form: expire: 有效时间 + read_only: 以只读权限分享 submit: 生成授权码 close: 关闭 success_dialog: diff --git a/ui/lib/client/index.tsx b/ui/lib/client/index.tsx index 9dce0abfea..0366218332 100644 --- a/ui/lib/client/index.tsx +++ b/ui/lib/client/index.tsx @@ -66,7 +66,7 @@ function applyErrorHandlerInterceptor(instance: AxiosInstance) { } else { errCode = response?.data?.code } - if (errCode !== ERR_CODE_OTHER && i18next.exists(errCode)) { + if (errCode !== ERR_CODE_OTHER && i18next.exists(errCode ?? '')) { content = i18next.t(errCode) } else { content = diff --git a/ui/lib/client/translations/en.yaml b/ui/lib/client/translations/en.yaml index ac0693bf02..7512f5160d 100644 --- a/ui/lib/client/translations/en.yaml +++ b/ui/lib/client/translations/en.yaml @@ -3,7 +3,7 @@ error: network: Network connection error api: unauthorized: Session is expired. Please sign in again. - invalid_request: Bad Request + insufficient_privilege: You don't have sufficient privilege to perform this action. user: signin: invalid_code: Authorization Code is invalid or expired diff --git a/ui/lib/client/translations/zh.yaml b/ui/lib/client/translations/zh.yaml index ca63881fca..2c11c409f0 100644 --- a/ui/lib/client/translations/zh.yaml +++ b/ui/lib/client/translations/zh.yaml @@ -3,7 +3,7 @@ error: network: 网络连接失败 api: unauthorized: 会话已过期,请重新登录 - invalid_request: 请求出错 + insufficient_privilege: 您没有足够的权限进行该操作 user: signin: invalid_code: 授权码无效或已过期 diff --git a/ui/lib/components/CopyLink/index.tsx b/ui/lib/components/CopyLink/index.tsx index 1bd0e764cc..c17fb4d21e 100644 --- a/ui/lib/components/CopyLink/index.tsx +++ b/ui/lib/components/CopyLink/index.tsx @@ -58,7 +58,7 @@ function CopyLink({ data, displayVariant = 'default' }: ICopyLinkProps) { return ( {!showCopied && ( - + {t(`component.copyLink.${transKeys[displayVariant]}`)}{' '} diff --git a/ui/lib/utils/auth.ts b/ui/lib/utils/auth.ts index fe4c5ec969..f397e1be22 100644 --- a/ui/lib/utils/auth.ts +++ b/ui/lib/utils/auth.ts @@ -1,15 +1,29 @@ +import { EventEmitter2 } from 'eventemitter2' + const tokenKey = 'dashboard_auth_token' +export const authEvents = new EventEmitter2() + +export const EVENT_TOKEN_CHANGED = 'tokenChanged' + export function getAuthToken() { return localStorage.getItem(tokenKey) } export function setAuthToken(token) { - localStorage.setItem(tokenKey, token) + const lastToken = getAuthToken() + if (lastToken !== token) { + localStorage.setItem(tokenKey, token) + authEvents.emit(EVENT_TOKEN_CHANGED, token) + } } export function clearAuthToken() { - localStorage.removeItem(tokenKey) + const lastToken = getAuthToken() + if (lastToken) { + localStorage.removeItem(tokenKey) + authEvents.emit(EVENT_TOKEN_CHANGED, null) + } } export function getAuthTokenAsBearer() { @@ -19,3 +33,9 @@ export function getAuthTokenAsBearer() { } return `Bearer ${token}` } + +export enum AuthTypes { + SQLUser = 0, + SharingCode = 1, + SSO = 2, +} diff --git a/ui/lib/utils/authSSO.ts b/ui/lib/utils/authSSO.ts new file mode 100644 index 0000000000..ffab77d150 --- /dev/null +++ b/ui/lib/utils/authSSO.ts @@ -0,0 +1,78 @@ +import client, { ErrorStrategy } from '@lib/client' +import { Modal } from 'antd' +import * as auth from './auth' +import { AuthTypes } from './auth' + +function newRandomString(length: number) { + let text = '' + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)) + } + return text +} + +function getBaseURL() { + return `${window.location.protocol}//${window.location.host}${window.location.pathname}` +} + +function getRedirectURL() { + return `${getBaseURL()}?sso_callback=1` +} + +export async function getAuthURL() { + const codeVerifier = newRandomString(128) + const state = newRandomString(32) + + sessionStorage.setItem('sso.codeVerifier', codeVerifier) + sessionStorage.setItem('sso.state', state) + const resp = await client + .getInstance() + .userSSOGetAuthURL(codeVerifier, getRedirectURL(), state) + return resp.data +} + +export function isSSOCallback() { + const p = new URLSearchParams(window.location.search) + return p.has('sso_callback') +} + +async function handleSSOCallbackInner() { + const p = new URLSearchParams(window.location.search) + if (p.get('state') !== sessionStorage.getItem('sso.state')) { + throw new Error( + 'Invalid OIDC state: You may see this error when your SSO sign in is outdated.' + ) + } + const r = await client.getInstance().userLogin( + { + type: AuthTypes.SSO, + extra: JSON.stringify({ + code: p.get('code'), + code_verifier: sessionStorage.getItem('sso.codeVerifier'), + redirect_url: getRedirectURL(), + }), + }, + { errorStrategy: ErrorStrategy.Custom } + ) + + sessionStorage.removeItem('sso.codeVerifier') + sessionStorage.removeItem('sso.state') + + auth.setAuthToken(r.data.token) + window.location.replace(getBaseURL()) +} + +export async function handleSSOCallback() { + try { + await handleSSOCallbackInner() + } catch (e) { + Modal.error({ + title: 'SSO Sign In Failed', + content: '' + e, + okText: 'Sign In Again', + onOk: () => window.location.replace(getBaseURL()), + }) + } +} diff --git a/ui/lib/utils/sentryHelpers.ts b/ui/lib/utils/sentryHelpers.ts index e996306457..09883bc082 100644 --- a/ui/lib/utils/sentryHelpers.ts +++ b/ui/lib/utils/sentryHelpers.ts @@ -75,7 +75,7 @@ export function initSentryRoutingInstrument() { } export function applySentryTracingInterceptor(instance: AxiosInstance) { - instance.interceptors.request.use(function (config) { + instance.interceptors.request.use((config) => { if (config.url && config.method) { const { pathname } = url.parse(config.url) const transaction = markStart(pathname!, 'http') @@ -86,8 +86,8 @@ export function applySentryTracingInterceptor(instance: AxiosInstance) { }) instance.interceptors.response.use( - function (response) { - const id = response.config.headers['x-sentry-trace'] + (response) => { + const id = response.config?.headers['x-sentry-trace'] if (id) { const { pathname } = url.parse(response.config.url!) markTag('http.status', response.status, id) @@ -95,8 +95,8 @@ export function applySentryTracingInterceptor(instance: AxiosInstance) { } return response }, - function (error) { - const id = error.config.headers['x-sentry-trace'] + (error) => { + const id = error?.config?.headers['x-sentry-trace'] if (id) { const { pathname } = url.parse(error.config.url) markTag(id, 'error', error.message) diff --git a/ui/lib/utils/store.ts b/ui/lib/utils/store.ts new file mode 100644 index 0000000000..34105e5432 --- /dev/null +++ b/ui/lib/utils/store.ts @@ -0,0 +1,53 @@ +import client, { + ErrorStrategy, + InfoInfoResponse, + InfoWhoAmIResponse, +} from '@lib/client' +import { Store } from 'pullstate' +import { authEvents, EVENT_TOKEN_CHANGED, getAuthToken } from './auth' + +interface StoreState { + whoAmI?: InfoWhoAmIResponse + appInfo?: InfoInfoResponse +} + +export const store = new Store({}) + +export const useIsWriteable = () => + store.useState((s) => Boolean(s.whoAmI && s.whoAmI.is_writeable)) + +export async function reloadWhoAmI() { + if (!getAuthToken()) { + store.update((s) => { + s.whoAmI = undefined + }) + return + } + + try { + const resp = await client.getInstance().infoWhoami({ + errorStrategy: ErrorStrategy.Custom, + }) + store.update((s) => { + s.whoAmI = resp.data + }) + } catch (ex) { + store.update((s) => { + s.whoAmI = undefined + }) + } +} + +export async function mustLoadAppInfo(): Promise { + const resp = await client.getInstance().infoGet({ + errorStrategy: ErrorStrategy.Custom, + }) + store.update((s) => { + s.appInfo = resp.data + }) + return resp.data +} + +authEvents.on(EVENT_TOKEN_CHANGED, async () => { + await reloadWhoAmI() +}) diff --git a/ui/package.json b/ui/package.json index 79d4b9c4ee..8d1d666723 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,6 +23,7 @@ "dayjs": "^1.9.6", "echarts": "^4.8.0", "echarts-for-react": "^2.0.16", + "eventemitter2": "^6.4.4", "history": "^5.0.0", "i18next": "^19.6.3", "i18next-browser-languagedetector": "^5.0.0", @@ -30,6 +31,7 @@ "moize": "^5.4.7", "nprogress": "^0.2.0", "office-ui-fabric-react": "^7.123.10", + "pullstate": "^1.22.1", "rc-animate": "^3.1.0", "react": "^16.13.1", "react-ace": "^9.1.1", @@ -86,6 +88,7 @@ "@types/lodash": "^4.14.158", "@types/node": "^14.0.27", "@types/react": "^16.9.43", + "@types/react-copy-to-clipboard": "^5.0.1", "@types/react-dom": "^16.9.8", "@types/webpack-env": "^1.15.2", "babel-plugin-dynamic-import-node": "^2.3.0", diff --git a/ui/yarn.lock b/ui/yarn.lock index fe712374e3..17d61d5dfb 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -3605,6 +3605,13 @@ "@types/react" "*" "@types/reactcss" "*" +"@types/react-copy-to-clipboard@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#7d9c2c0af52e2e8106ebe2b9fde4f02859af7a3d" + integrity sha512-CDuRrJWEIdfKFC4vbwpXT3vk0O4gA/I/Kxu/1npUvGc2Yey5swPvsgO3JEnQkIUwdnYUbwUYDE/fTFQVgqr4oA== + dependencies: + "@types/react" "*" + "@types/react-dom@^16.0.3", "@types/react-dom@^16.9.8": version "16.9.8" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" @@ -8023,6 +8030,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +eventemitter2@^6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== + eventemitter3@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" @@ -9632,6 +9644,11 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immer@^8.0.1: + version "8.0.4" + resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.4.tgz#3a21605a4e2dded852fb2afd208ad50969737b7a" + integrity sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -13823,6 +13840,14 @@ public-encrypt@^4.0.0: randombytes "^2.0.1" safe-buffer "^5.1.2" +pullstate@^1.22.1: + version "1.22.1" + resolved "https://registry.yarnpkg.com/pullstate/-/pullstate-1.22.1.tgz#ffdde634e8c721907de8e6d37a85c6083137ee8a" + integrity sha512-Xu3umsGOG6qCQ4IWxKSEikQqdR7GDsTHQPE7wquzQENMRZbPeHURA9dZgH/9ktuhDh3D1qnIDI9PyPftabme0A== + dependencies: + fast-deep-equal "^3.1.3" + immer "^8.0.1" + pump@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" @@ -14404,9 +14429,9 @@ react-color@^2.17.0: tinycolor2 "^1.4.1" react-copy-to-clipboard@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.2.tgz#d82a437e081e68dfca3761fbd57dbf2abdda1316" - integrity sha512-/2t5mLMMPuN5GmdXo6TebFa8IoFxZ+KTDDqYhcDm0PhkgEzSxVvIX26G20s1EB02A4h2UZgwtfymZ3lGJm0OLg== + version "5.0.3" + resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.3.tgz#2a0623b1115a1d8c84144e9434d3342b5af41ab4" + integrity sha512-9S3j+m+UxDZOM0Qb8mhnT/rMR0NGSrj9A/073yz2DSxPMYhmYFBMYIdI2X4o8AjOjyFsSNxDRnCX6s/gRxpriw== dependencies: copy-to-clipboard "^3" prop-types "^15.5.8"