diff --git a/cmd/ks-iam/app/options/options.go b/cmd/ks-iam/app/options/options.go index 8b3e3c261..d001257ad 100644 --- a/cmd/ks-iam/app/options/options.go +++ b/cmd/ks-iam/app/options/options.go @@ -37,9 +37,10 @@ type ServerRunOptions struct { MySQLOptions *mysql.MySQLOptions AdminEmail string AdminPassword string - TokenExpireTime string + TokenIdleTimeout string JWTSecret string AuthRateLimit string + EnableMultiLogin bool } func NewServerRunOptions() *ServerRunOptions { @@ -60,9 +61,10 @@ func (s *ServerRunOptions) Flags() (fss cliflag.NamedFlagSets) { s.GenericServerRunOptions.AddFlags(fs) fs.StringVar(&s.AdminEmail, "admin-email", "admin@kubesphere.io", "default administrator's email") fs.StringVar(&s.AdminPassword, "admin-password", "passw0rd", "default administrator's password") - fs.StringVar(&s.TokenExpireTime, "token-expire-time", "2h", "token expire time,valid time units are \"ns\",\"us\",\"ms\",\"s\",\"m\",\"h\"") + fs.StringVar(&s.TokenIdleTimeout, "token-idle-timeout", "30m", "tokens that are idle beyond that time will expire,0s means the token has no expiration time. valid time units are \"ns\",\"us\",\"ms\",\"s\",\"m\",\"h\"") fs.StringVar(&s.JWTSecret, "jwt-secret", "", "jwt secret") fs.StringVar(&s.AuthRateLimit, "auth-rate-limit", "5/30m", "specifies the maximum number of authentication attempts permitted and time interval,valid time units are \"s\",\"m\",\"h\"") + fs.BoolVar(&s.EnableMultiLogin, "enable-multi-login", false, "allow one account to have multiple sessions") s.KubernetesOptions.AddFlags(fss.FlagSet("kubernetes")) s.LdapOptions.AddFlags(fss.FlagSet("ldap")) diff --git a/cmd/ks-iam/app/server.go b/cmd/ks-iam/app/server.go index 14370e1cb..8a6b52794 100644 --- a/cmd/ks-iam/app/server.go +++ b/cmd/ks-iam/app/server.go @@ -24,6 +24,7 @@ import ( cliflag "k8s.io/component-base/cli/flag" "k8s.io/klog" "kubesphere.io/kubesphere/cmd/ks-iam/app/options" + "kubesphere.io/kubesphere/pkg/apis" "kubesphere.io/kubesphere/pkg/apiserver/runtime" "kubesphere.io/kubesphere/pkg/informers" "kubesphere.io/kubesphere/pkg/models/iam" @@ -35,9 +36,6 @@ import ( "kubesphere.io/kubesphere/pkg/utils/signals" "kubesphere.io/kubesphere/pkg/utils/term" "net/http" - "time" - - "kubesphere.io/kubesphere/pkg/apis" ) func NewAPIServerCommand() *cobra.Command { @@ -94,15 +92,10 @@ func Run(s *options.ServerRunOptions, stopChan <-chan struct{}) error { client.NewClientSetFactory(csop, stopChan) - expireTime, err := time.ParseDuration(s.TokenExpireTime) - - if err != nil { - return err - } - waitForResourceSync(stopChan) - err = iam.Init(s.AdminEmail, s.AdminPassword, expireTime, s.AuthRateLimit) + err := iam.Init(s.AdminEmail, s.AdminPassword, s.TokenIdleTimeout, s.AuthRateLimit, s.EnableMultiLogin) + jwtutil.Setup(s.JWTSecret) if err != nil { diff --git a/pkg/apigateway/caddy-plugin/authenticate/authenticate.go b/pkg/apigateway/caddy-plugin/authenticate/authenticate.go index 3ee4fe11a..e30362461 100644 --- a/pkg/apigateway/caddy-plugin/authenticate/authenticate.go +++ b/pkg/apigateway/caddy-plugin/authenticate/authenticate.go @@ -20,13 +20,16 @@ package authenticate import ( "errors" "fmt" + "github.com/go-redis/redis" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/klog" "log" "net/http" "strconv" "strings" + "time" "github.com/dgrijalva/jwt-go" "github.com/mholt/caddy/caddyhttp/httpserver" @@ -38,9 +41,12 @@ type Auth struct { } type Rule struct { - Secret []byte - Path string - ExceptedPath []string + Secret []byte + Path string + RedisOptions *redis.Options + TokenIdleTimeout time.Duration + RedisClient *redis.Client + ExceptedPath []string } type User struct { @@ -87,7 +93,7 @@ func (h Auth) ServeHTTP(resp http.ResponseWriter, req *http.Request) (int, error func (h Auth) InjectContext(req *http.Request, token *jwt.Token) (*http.Request, error) { - payLoad, ok := token.Claims.(jwt.MapClaims) + payload, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("invalid payload") @@ -101,14 +107,14 @@ func (h Auth) InjectContext(req *http.Request, token *jwt.Token) (*http.Request, usr := &user.DefaultInfo{} - username, ok := payLoad["username"].(string) + username, ok := payload["username"].(string) if ok && username != "" { req.Header.Set("X-Token-Username", username) usr.Name = username } - uid := payLoad["uid"] + uid := payload["uid"] if uid != nil { switch uid.(type) { @@ -123,7 +129,7 @@ func (h Auth) InjectContext(req *http.Request, token *jwt.Token) (*http.Request, } } - groups, ok := payLoad["groups"].([]string) + groups, ok := payload["groups"].([]string) if ok && len(groups) > 0 { req.Header.Set("X-Token-Groups", strings.Join(groups, ",")) usr.Groups = groups @@ -160,10 +166,46 @@ func (h Auth) Validate(uToken string) (*jwt.Token, error) { token, err := jwt.Parse(uToken, h.ProvideKey) if err != nil { + klog.Errorln(err) return nil, err } - return token, nil + payload, ok := token.Claims.(jwt.MapClaims) + + if !ok { + err := fmt.Errorf("invalid payload") + klog.Errorln(err) + return nil, err + } + + username, ok := payload["username"].(string) + + if !ok { + err := fmt.Errorf("invalid payload") + klog.Errorln(err) + return nil, err + } + + if _, ok = payload["exp"]; ok { + // allow static token when contain expiration time + return token, nil + } + + tokenKey := fmt.Sprintf("kubesphere:users:%s:token:%s", username, uToken) + + exist, err := h.Rule.RedisClient.Exists(tokenKey).Result() + if err != nil { + klog.Error(err) + return nil, err + } + + if exist == 1 { + // reset expiration time if token exist + h.Rule.RedisClient.Expire(tokenKey, h.Rule.TokenIdleTimeout) + return token, nil + } else { + return nil, errors.New("illegal token") + } } func (h Auth) HandleUnauthorized(w http.ResponseWriter, err error) int { diff --git a/pkg/apigateway/caddy-plugin/authenticate/auto_load.go b/pkg/apigateway/caddy-plugin/authenticate/auto_load.go index 53d47466b..9cfcea063 100644 --- a/pkg/apigateway/caddy-plugin/authenticate/auto_load.go +++ b/pkg/apigateway/caddy-plugin/authenticate/auto_load.go @@ -19,7 +19,9 @@ package authenticate import ( "fmt" + "github.com/go-redis/redis" "strings" + "time" "github.com/mholt/caddy" "github.com/mholt/caddy/caddyhttp/httpserver" @@ -33,17 +35,27 @@ func Setup(c *caddy.Controller) error { return err } + rule.RedisClient = redis.NewClient(rule.RedisOptions) + c.OnStartup(func() error { + if err := rule.RedisClient.Ping().Err(); err != nil { + return err + } fmt.Println("Authenticate middleware is initiated") return nil }) + c.OnShutdown(func() error { + return rule.RedisClient.Close() + }) + httpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler { return &Auth{Next: next, Rule: rule} }) return nil } + func parse(c *caddy.Controller) (Rule, error) { rule := Rule{ExceptedPath: make([]string, 0)} @@ -61,6 +73,34 @@ func parse(c *caddy.Controller) (Rule, error) { rule.Path = c.Val() + if c.NextArg() { + return rule, c.ArgErr() + } + case "token-idle-timeout": + if !c.NextArg() { + return rule, c.ArgErr() + } + + if timeout, err := time.ParseDuration(c.Val()); err != nil { + return rule, c.ArgErr() + } else { + rule.TokenIdleTimeout = timeout + } + + if c.NextArg() { + return rule, c.ArgErr() + } + case "redis-url": + if !c.NextArg() { + return rule, c.ArgErr() + } + + if redisOptions, err := redis.ParseURL(c.Val()); err != nil { + return rule, c.ArgErr() + } else { + rule.RedisOptions = redisOptions + } + if c.NextArg() { return rule, c.ArgErr() } diff --git a/pkg/models/iam/im.go b/pkg/models/iam/im.go index 34f7a1665..d7444927d 100644 --- a/pkg/models/iam/im.go +++ b/pkg/models/iam/im.go @@ -53,10 +53,11 @@ import ( var ( adminEmail string adminPassword string - tokenExpireTime time.Duration + tokenIdleTimeout time.Duration maxAuthFailed int authTimeInterval time.Duration initUsers []initUser + enableMultiLogin bool ) type initUser struct { @@ -69,13 +70,15 @@ const ( authRateLimitRegex = `(\d+)/(\d+[s|m|h])` defaultMaxAuthFailed = 5 defaultAuthTimeInterval = 30 * time.Minute + defaultTokenIdleTimeout = 30 * time.Minute ) -func Init(email, password string, expireTime time.Duration, authRateLimit string) error { +func Init(email, password, idleTimeout, authRateLimit string, multiLogin bool) error { adminEmail = email adminPassword = password - tokenExpireTime = expireTime + tokenIdleTimeout = parseTokenIdleTimeout(idleTimeout) maxAuthFailed, authTimeInterval = parseAuthRateLimit(authRateLimit) + enableMultiLogin = multiLogin err := checkAndCreateDefaultUser() @@ -94,6 +97,15 @@ func Init(email, password string, expireTime time.Duration, authRateLimit string return nil } +func parseTokenIdleTimeout(tokenExpirationTime string) time.Duration { + duration, err := time.ParseDuration(tokenExpirationTime) + if err != nil { + return defaultTokenIdleTimeout + } else { + return duration + } +} + func parseAuthRateLimit(authRateLimit string) (int, time.Duration) { regex := regexp.MustCompile(authRateLimitRegex) groups := regex.FindStringSubmatch(authRateLimit) @@ -216,6 +228,9 @@ func createUserBaseDN() error { return err } conn, err := client.NewConn() + if err != nil { + return err + } defer conn.Close() groupsCreateRequest := ldap.NewAddRequest(client.UserSearchBase(), nil) groupsCreateRequest.Attribute("objectClass", []string{"organizationalUnit", "top"}) @@ -230,6 +245,9 @@ func createGroupsBaseDN() error { return err } conn, err := client.NewConn() + if err != nil { + return err + } defer conn.Close() groupsCreateRequest := ldap.NewAddRequest(client.GroupSearchBase(), nil) groupsCreateRequest.Attribute("objectClass", []string{"organizationalUnit", "top"}) @@ -295,7 +313,7 @@ func Login(username string, password string, ip string) (*models.Token, error) { klog.Infoln("auth failed", username, err) if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { - loginFailedRecord := fmt.Sprintf("kubesphere:authfailed:%s:%d", username, time.Now().UnixNano()) + loginFailedRecord := fmt.Sprintf("kubesphere:authfailed:%s:%d", uid, time.Now().UnixNano()) redisClient.Set(loginFailedRecord, "", authTimeInterval) } @@ -304,14 +322,38 @@ func Login(username string, password string, ip string) (*models.Token, error) { claims := jwt.MapClaims{} - if tokenExpireTime > 0 { - claims["exp"] = time.Now().Add(tokenExpireTime).Unix() - } + // do not set expiration time claims["username"] = uid claims["email"] = email + claims["iat"] = time.Now().Unix() token := jwtutil.MustSigned(claims) + if !enableMultiLogin { + // multi login not allowed, remove the previous token + sessions, err := redisClient.Keys(fmt.Sprintf("kubesphere:users:%s:token:*", uid)).Result() + + if err != nil { + klog.Errorln(err) + return nil, err + } + + if len(sessions) > 0 { + klog.V(4).Infoln("revoke token", sessions) + err = redisClient.Del(sessions...).Err() + if err != nil { + klog.Errorln(err) + return nil, err + } + } + } + + // cache token with expiration time + if err = redisClient.Set(fmt.Sprintf("kubesphere:users:%s:token:%s", uid, token), token, tokenIdleTimeout).Err(); err != nil { + klog.Errorln(err) + return nil, err + } + loginLog(uid, ip) return &models.Token{Token: token}, nil @@ -443,7 +485,6 @@ func ListUsers(conditions *params.Conditions, orderBy string, reverse bool, limi if i >= offset && len(items) < limit { - user.AvatarUrl = getAvatar(user.Username) user.LastLoginTime = getLastLoginTime(user.Username) clusterRole, err := GetUserClusterRole(user.Username) if err != nil { @@ -480,8 +521,6 @@ func DescribeUser(username string) (*models.User, error) { user.Groups = groups } - user.AvatarUrl = getAvatar(username) - return user, nil } @@ -582,37 +621,6 @@ func getLastLoginTime(username string) string { return "" } -func setAvatar(username, avatar string) error { - redis, err := clientset.ClientSets().Redis() - if err != nil { - return err - } - - _, err = redis.HMSet("kubesphere:users:avatar", map[string]interface{}{"username": avatar}).Result() - return err -} - -func getAvatar(username string) string { - redis, err := clientset.ClientSets().Redis() - if err != nil { - return "" - } - - avatar, err := redis.HMGet("kubesphere:users:avatar", username).Result() - - if err != nil { - return "" - } - - if len(avatar) > 0 { - if url, ok := avatar[0].(string); ok { - return url - } - } - - return "" -} - func DeleteUser(username string) error { client, err := clientset.ClientSets().Ldap() @@ -876,10 +884,6 @@ func CreateUser(user *models.User) (*models.User, error) { return nil, err } - if user.AvatarUrl != "" { - setAvatar(user.Username, user.AvatarUrl) - } - if user.ClusterRole != "" { err := CreateClusterRoleBinding(user.Username, user.ClusterRole) @@ -1022,15 +1026,6 @@ func UpdateUser(user *models.User) (*models.User, error) { userModifyRequest.Replace("userPassword", []string{user.Password}) } - if user.AvatarUrl != "" { - err = setAvatar(user.Username, user.AvatarUrl) - } - - if err != nil { - klog.Error(err) - return nil, err - } - err = conn.Modify(userModifyRequest) if err != nil {