* implement LDAP mock client Signed-off-by: hongming <talonwan@yunify.com> * update Signed-off-by: hongming <talonwan@yunify.com> * update Signed-off-by: hongming <talonwan@yunify.com> * resolve conflict Signed-off-by: hongming <talonwan@yunify.com>
444 lines
12 KiB
Go
444 lines
12 KiB
Go
/*
|
|
*
|
|
* Copyright 2020 The KubeSphere Authors.
|
|
*
|
|
* 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,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
* /
|
|
*/
|
|
package iam
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/dgrijalva/jwt-go"
|
|
"github.com/go-ldap/ldap"
|
|
"github.com/go-redis/redis"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/oauth2"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
"k8s.io/klog"
|
|
"kubesphere.io/kubesphere/pkg/models"
|
|
"kubesphere.io/kubesphere/pkg/server/params"
|
|
ldappool "kubesphere.io/kubesphere/pkg/simple/client/ldap"
|
|
"kubesphere.io/kubesphere/pkg/utils/jwtutil"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type IdentityManagementInterface interface {
|
|
CreateUser(user *User) (*User, error)
|
|
DeleteUser(username string) error
|
|
DescribeUser(username string) (*User, error)
|
|
Login(username, password, ip string) (*oauth2.Token, error)
|
|
ModifyUser(user *User) (*User, error)
|
|
ListUsers(conditions *params.Conditions, orderBy string, reverse bool, limit, offset int) (*models.PageableResponse, error)
|
|
GetUserRoles(username string) ([]*rbacv1.Role, error)
|
|
GetUserRole(namespace string, username string) (*rbacv1.Role, error)
|
|
}
|
|
|
|
type Config struct {
|
|
authRateLimit string
|
|
maxAuthFailed int
|
|
authTimeInterval time.Duration
|
|
tokenIdleTimeout time.Duration
|
|
enableMultiLogin bool
|
|
}
|
|
|
|
type imOperator struct {
|
|
config Config
|
|
ldap ldappool.Client
|
|
redis redis.Client
|
|
}
|
|
|
|
const (
|
|
authRateLimitRegex = `(\d+)/(\d+[s|m|h])`
|
|
defaultMaxAuthFailed = 5
|
|
defaultAuthTimeInterval = 30 * time.Minute
|
|
mailAttribute = "mail"
|
|
uidAttribute = "uid"
|
|
descriptionAttribute = "description"
|
|
preferredLanguageAttribute = "preferredLanguage"
|
|
createTimestampAttribute = "createTimestampAttribute"
|
|
dateTimeLayout = "20060102150405Z"
|
|
)
|
|
|
|
var (
|
|
AuthRateLimitExceeded = errors.New("user auth rate limit exceeded")
|
|
UserAlreadyExists = errors.New("user already exists")
|
|
UserNotExists = errors.New("user not exists")
|
|
)
|
|
|
|
func NewIMOperator(ldap ldappool.Client, config Config) *imOperator {
|
|
imOperator := &imOperator{ldap: ldap, config: config}
|
|
return imOperator
|
|
}
|
|
|
|
// TODO init in controller
|
|
func (im *imOperator) Init() error {
|
|
|
|
userSearchBase := &ldap.AddRequest{
|
|
DN: im.ldap.UserSearchBase(),
|
|
Attributes: []ldap.Attribute{{
|
|
Type: "objectClass",
|
|
Vals: []string{"organizationalUnit", "top"},
|
|
}, {
|
|
Type: "ou",
|
|
Vals: []string{"Users"},
|
|
}},
|
|
Controls: nil,
|
|
}
|
|
|
|
err := im.createIfNotExists(userSearchBase)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
groupSearchBase := &ldap.AddRequest{
|
|
DN: im.ldap.GroupSearchBase(),
|
|
Attributes: []ldap.Attribute{{
|
|
Type: "objectClass",
|
|
Vals: []string{"organizationalUnit", "top"},
|
|
}, {
|
|
Type: "ou",
|
|
Vals: []string{"Groups"},
|
|
}},
|
|
Controls: nil,
|
|
}
|
|
|
|
err = im.createIfNotExists(groupSearchBase)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (im *imOperator) createIfNotExists(createRequest *ldap.AddRequest) error {
|
|
conn, err := im.ldap.NewConn()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
|
|
searchRequest := ldap.NewSearchRequest(
|
|
createRequest.DN,
|
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
|
"(objectClass=*)",
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
_, err = conn.Search(searchRequest)
|
|
|
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
|
err = conn.Add(createRequest)
|
|
}
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (im *imOperator) ModifyUser(user *User) (*User, error) {
|
|
conn, err := im.ldap.NewConn()
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
defer conn.Close()
|
|
|
|
dn := fmt.Sprintf("uid=%s,%s", user.Username, im.ldap.UserSearchBase())
|
|
userModifyRequest := ldap.NewModifyRequest(dn, nil)
|
|
|
|
if user.Description != "" {
|
|
userModifyRequest.Replace("description", []string{user.Description})
|
|
}
|
|
|
|
if user.Lang != "" {
|
|
userModifyRequest.Replace("preferredLanguage", []string{user.Lang})
|
|
}
|
|
|
|
if user.Password != "" {
|
|
userModifyRequest.Replace("userPassword", []string{user.Password})
|
|
}
|
|
|
|
err = conn.Modify(userModifyRequest)
|
|
|
|
if err != nil {
|
|
klog.Error(err)
|
|
return nil, err
|
|
}
|
|
|
|
// clear auth failed record
|
|
if user.Password != "" {
|
|
|
|
records, err := im.redis.Keys(fmt.Sprintf("kubesphere:authfailed:%s:*", user.Username)).Result()
|
|
|
|
if err == nil {
|
|
im.redis.Del(records...)
|
|
}
|
|
}
|
|
|
|
return im.DescribeUser(user.Username)
|
|
}
|
|
|
|
func (im *imOperator) Login(username, password, ip string) (*oauth2.Token, error) {
|
|
|
|
records, err := im.redis.Keys(fmt.Sprintf("kubesphere:authfailed:%s:*", username)).Result()
|
|
|
|
if err != nil {
|
|
klog.Error(err)
|
|
return nil, err
|
|
}
|
|
|
|
if len(records) >= im.config.maxAuthFailed {
|
|
return nil, AuthRateLimitExceeded
|
|
}
|
|
|
|
user, err := im.DescribeUser(username)
|
|
|
|
conn, err := im.ldap.NewConn()
|
|
if err != nil {
|
|
klog.Error(err)
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
dn := fmt.Sprintf("uid=%s,%s", user.Username, im.ldap.UserSearchBase())
|
|
|
|
// bind as the user to verify their password
|
|
err = conn.Bind(dn, password)
|
|
|
|
if err != nil {
|
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
|
cacheKey := fmt.Sprintf("kubesphere:authfailed:%s:%d", user.Username, time.Now().UnixNano())
|
|
im.redis.Set(cacheKey, "", im.config.authTimeInterval)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
loginTime := time.Now()
|
|
// token without expiration time will auto sliding
|
|
claims := jwt.MapClaims{
|
|
"iat": loginTime.Unix(),
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
}
|
|
token := jwtutil.MustSigned(claims)
|
|
|
|
if !im.config.enableMultiLogin {
|
|
// multi login not allowed, remove the previous token
|
|
cacheKey := fmt.Sprintf("kubesphere:users:%s:token:*", user.Username)
|
|
sessions, err := im.redis.Keys(cacheKey).Result()
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
if len(sessions) > 0 {
|
|
klog.V(4).Infoln("revoke token", sessions)
|
|
err = im.redis.Del(sessions...).Err()
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// cache token with expiration time
|
|
cacheKey := fmt.Sprintf("kubesphere:users:%s:token:%s", user.Username, token)
|
|
if err = im.redis.Set(cacheKey, token, im.config.tokenIdleTimeout).Err(); err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
im.loginRecord(user.Username, ip, loginTime)
|
|
|
|
return &oauth2.Token{AccessToken: token}, nil
|
|
}
|
|
|
|
func (im *imOperator) loginRecord(username, ip string, loginTime time.Time) {
|
|
if ip != "" {
|
|
im.redis.RPush(fmt.Sprintf("kubesphere:users:%s:login-log", username), fmt.Sprintf("%s,%s", loginTime.UTC().Format("2006-01-02T15:04:05Z"), ip))
|
|
im.redis.LTrim(fmt.Sprintf("kubesphere:users:%s:login-log", username), -10, -1)
|
|
}
|
|
}
|
|
|
|
func (im *imOperator) LoginHistory(username string) ([]string, error) {
|
|
data, err := im.redis.LRange(fmt.Sprintf("kubesphere:users:%s:login-log", username), -10, -1).Result()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func (im *imOperator) ListUsers(conditions *params.Conditions, orderBy string, reverse bool, limit, offset int) (*models.PageableResponse, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (im *imOperator) DescribeUser(username string) (*User, error) {
|
|
conn, err := im.ldap.NewConn()
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
filter := fmt.Sprintf("(&(objectClass=inetOrgPerson)(|(uid=%s)(mail=%s)))", username, username)
|
|
|
|
searchRequest := ldap.NewSearchRequest(
|
|
im.ldap.UserSearchBase(),
|
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
|
filter,
|
|
[]string{mailAttribute, descriptionAttribute, preferredLanguageAttribute, createTimestampAttribute},
|
|
nil,
|
|
)
|
|
|
|
result, err := conn.Search(searchRequest)
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
if len(result.Entries) != 1 {
|
|
return nil, UserNotExists
|
|
}
|
|
|
|
entry := result.Entries[0]
|
|
|
|
return convertLdapEntryToUser(entry), nil
|
|
}
|
|
|
|
func convertLdapEntryToUser(entry *ldap.Entry) *User {
|
|
username := entry.GetAttributeValue(uidAttribute)
|
|
email := entry.GetAttributeValue(mailAttribute)
|
|
description := entry.GetAttributeValue(descriptionAttribute)
|
|
lang := entry.GetAttributeValue(preferredLanguageAttribute)
|
|
createTimestamp, err := time.Parse(dateTimeLayout, entry.GetAttributeValue(createTimestampAttribute))
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
}
|
|
return &User{Username: username, Email: email, Description: description, Lang: lang, CreateTime: createTimestamp}
|
|
}
|
|
|
|
func (im *imOperator) getLastLoginTime(username string) string {
|
|
cacheKey := fmt.Sprintf("kubesphere:users:%s:login-log", username)
|
|
lastLogin, err := im.redis.LRange(cacheKey, -1, -1).Result()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(lastLogin) > 0 {
|
|
return strings.Split(lastLogin[0], ",")[0]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (im *imOperator) DeleteUser(username string) error {
|
|
conn, err := im.ldap.NewConn()
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return err
|
|
}
|
|
|
|
defer conn.Close()
|
|
|
|
deleteRequest := ldap.NewDelRequest(fmt.Sprintf("uid=%s,%s", username, im.ldap.UserSearchBase()), nil)
|
|
|
|
if err = conn.Del(deleteRequest); err != nil {
|
|
klog.Errorln(err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (im *imOperator) CreateUser(user *User) (*User, error) {
|
|
user.Username = strings.TrimSpace(user.Username)
|
|
user.Email = strings.TrimSpace(user.Email)
|
|
user.Password = strings.TrimSpace(user.Password)
|
|
user.Description = strings.TrimSpace(user.Description)
|
|
|
|
existed, err := im.DescribeUser(user.Username)
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
if existed != nil {
|
|
return nil, UserAlreadyExists
|
|
}
|
|
|
|
uidNumber := im.uidNumberNext()
|
|
|
|
createRequest := ldap.NewAddRequest(fmt.Sprintf("uid=%s,%s", user.Username, im.ldap.UserSearchBase()), nil)
|
|
createRequest.Attribute("objectClass", []string{"inetOrgPerson", "posixAccount", "top"})
|
|
createRequest.Attribute("cn", []string{user.Username}) // RFC4519: common name(s) for which the entity is known by
|
|
createRequest.Attribute("sn", []string{" "}) // RFC2256: last (family) name(s) for which the entity is known by
|
|
createRequest.Attribute("gidNumber", []string{"500"}) // RFC2307: An integer uniquely identifying a group in an administrative domain
|
|
createRequest.Attribute("homeDirectory", []string{"/home/" + user.Username}) // The absolute path to the home directory
|
|
createRequest.Attribute("uid", []string{user.Username}) // RFC4519: user identifier
|
|
createRequest.Attribute("uidNumber", []string{strconv.Itoa(uidNumber)}) // RFC2307: An integer uniquely identifying a user in an administrative domain
|
|
createRequest.Attribute("mail", []string{user.Email}) // RFC1274: RFC822 Mailbox
|
|
createRequest.Attribute("userPassword", []string{user.Password}) // RFC4519/2307: password of user
|
|
if user.Lang != "" {
|
|
createRequest.Attribute("preferredLanguage", []string{user.Lang})
|
|
}
|
|
if user.Description != "" {
|
|
createRequest.Attribute("description", []string{user.Description}) // RFC4519: descriptive information
|
|
}
|
|
|
|
conn, err := im.ldap.NewConn()
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
err = conn.Add(createRequest)
|
|
|
|
if err != nil {
|
|
klog.Errorln(err)
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (im *imOperator) uidNumberNext() int {
|
|
// TODO fix me
|
|
return 0
|
|
}
|
|
func (im *imOperator) GetUserRoles(username string) ([]*rbacv1.Role, error) {
|
|
panic("implement me")
|
|
}
|
|
|
|
func (im *imOperator) GetUserRole(namespace string, username string) (*rbacv1.Role, error) {
|
|
panic("implement me")
|
|
}
|