Skip to content

K8S自定义webhook实现认证管理 原创

大家好,我是乔克。

在 Kubernetes 中,APIServer 是整个集群的中枢神经,它不仅连接了各个模块,更是为整个集群提供了访问控制能力。

Kubernetes API 的每个请求都要经过多阶段的访问控制才会被接受,包括认证、授权、准入,如下所示。

49055d5808f8f92a620acb271a2e112a MD5

客户端(普通账户、ServiceAccount 等)想要访问 Kubernetes 中的资源,需要通过经过 APIServer 的三大步骤才能正常访问,三大步骤如下:

  1. Authentication 认证阶段:判断请求用户是否为能够访问集群的合法用户。如果用户是个非法用户,那 apiserver 会返回一个 401 的状态码,并终止该请求;
  2. 如果用户合法的话,我们的 apiserver 会进入到访问控制的第二阶段 Authorization:授权阶段。在该阶段中 apiserver 会判断用户是否有权限进行请求中的操作。如果无权进行操作,apiserver 会返回 403 的状态码,并同样终止该请求;
  3. 如果用户有权进行该操作的话,访问控制会进入到第三个阶段:AdmissionControl。在该阶段中 apiserver 的 admission controller 会判断请求是否是一个安全合规的请求。如果最终验证通过的话,访问控制流程才会结束。

这篇文章主要和大家讨论认证环节。

认证

Kubernetes 中支持多种认证机制,也支持多种认证插件,在认证过程中,只要一个通过则表示认证通过。

常用的认证插件有:

  • X509 证书
  • 静态 Token
  • ServiceAccount
  • OpenID
  • Webhook
  • ...

这里不会把每种认证插件都介绍一下,主要讲讲 Webhook 的使用场景。

在企业中,大部分都会有自己的账户中心,用于管理员工的账户以及权限,而在 K8s 集群中,也需要进行账户管理,如果能直接使用现有的账户系统是不是会方便很多?

K8s 的 Webhook 就可以实现这种需求,Webhook 是一个 HTTP 回调,通过一个条件触发 HTTP POST 请求发送到 Webhook 服务端,服务端根据请求数据进行处理。

下面就带大家从 0 到 1 开发一个认证服务。

开发 Webhook

简介

WebHook 的功能主要是接收 APIServer 的认证请求,然后调用不同的认证服务进行认证,如下所示。

6a2bd48bf787b2048d2588dbf75d882b MD5

这里只是做一个 Webhook 的例子,目前主要实现了GithubLDAP认证,当然,认证部分的功能比较单一,没有考虑复杂的场景。

Webhook 开发

开发环境

| 软件 | 版本 |

| --- | --- |

| Go | 1.17.3 |

| Kubernetes | v1.22.3 |

| System | CentOS7.6 |

构建符合规范的 Webhook

在开发 Webhook 的时候,需要符合 Kubernetes 的规范,具体如下:

  • URL:https://auth.example.com/auth
  • Method:POST
  • Input 参数
json
{
  "apiVersion": "authentication.k8s.io/v1beta1",

  "kind": "TokenReview",

  "spec": {
    "token": "<持有者令牌>"
  }
}
  • Output 参数

如果成功会返回:

json
{
  "apiVersion": "authentication.k8s.io/v1beta1",

  "kind": "TokenReview",

  "status": {
    "authenticated": true,

    "user": {
      "username": "janedoe@example.com",

      "uid": "42",

      "groups": ["developers", "qa"],

      "extra": {
        "extrafield1": ["extravalue1", "extravalue2"]
      }
    }
  }
}

如果不成功,会返回:

json
{
  "apiVersion": "authentication.k8s.io/v1beta1",

  "kind": "TokenReview",

  "status": {
    "authenticated": false
  }
}

远程服务应该会填充请求的 status 字段,以标明登录操作是否成功。

开发认证服务

(1)创建项目并初始化 go mod
yaml
# mkdir kubernetes-auth-webhook

# cd kubernetes-auth-webhook

# go mod init
(2)在项目根目录下创建 webhook.go,写入如下内容
go

package main



import (

    "encoding/json"

    "github.com/golang/glog"

    authentication "k8s.io/api/authentication/v1beta1"

    "k8s.io/klog/v2"

    "net/http"

    "strings"

)



type WebHookServer struct {

    server *http.Server

}



func (ctx *WebHookServer) serve(w http.ResponseWriter, r *http.Request) {

    // 从APIServer中取出body

    // 将body进行拆分, 取出type

    // 根据type, 取出不同的认证数据

    var req authentication.TokenReview

    decoder := json.NewDecoder(r.Body)

    err := decoder.Decode(&req)

    if err != nil {

        klog.Error(err, "decoder request body error.")

        req.Status = authentication.TokenReviewStatus{Authenticated: false}

        w.WriteHeader(http.StatusUnauthorized)

        _ = json.NewEncoder(w).Encode(req)

        return

    }

    // 判断token是否包含':'

    // 如果不包含,则返回认证失败

    if !(strings.Contains(req.Spec.Token, ":")) {

        klog.Error(err, "token invalied.")

        req.Status = authentication.TokenReviewStatus{Authenticated: false}

        //req.Status = map[string]interface{}{"authenticated": false}

        w.WriteHeader(http.StatusUnauthorized)

        _ = json.NewEncoder(w).Encode(req)

        return

    }

    // split token, 获取type

    tokenSlice := strings.SplitN(req.Spec.Token, ":", -1)

    glog.Infof("tokenSlice: ", tokenSlice)

    hookType := tokenSlice[0]

    switch hookType {

    case "github":

        githubToken := tokenSlice[1]

        err := authByGithub(githubToken)

        if err != nil {

            klog.Error(err, "auth by github error")

            req.Status = authentication.TokenReviewStatus{Authenticated: false}

            w.WriteHeader(http.StatusUnauthorized)

            _ = json.NewEncoder(w).Encode(req)

            return

        }

        klog.Info("auth by github success")

        req.Status = authentication.TokenReviewStatus{Authenticated: true}

        w.WriteHeader(http.StatusOK)

        _ = json.NewEncoder(w).Encode(req)

        return

    case "ldap":

        username := tokenSlice[1]

        password := tokenSlice[2]

        err := authByLdap(username, password)

        if err != nil {

            klog.Error(err, "auth by ldap error")

            req.Status = authentication.TokenReviewStatus{Authenticated: false}

            //req.Status = map[string]interface{}{"authenticated": false}

            w.WriteHeader(http.StatusUnauthorized)

            _ = json.NewEncoder(w).Encode(req)

            return

        }

        klog.Info("auth by ldap success")

        req.Status = authentication.TokenReviewStatus{Authenticated: true}

        //req.Status = map[string]interface{}{"authenticated": true}

        w.WriteHeader(http.StatusOK)

        _ = json.NewEncoder(w).Encode(req)

        return

    }

}

主要是解析认证的请求 Token,然后将 Token 进行拆分判断是需要什么认证,Token 的样例如下:

  • Github 认证:github:<github-token>
  • LDAP 认证:ldap:<ldap-username>:<ldap-password>

这样就可以获取到用户想用哪种认证,再掉具体的认证服务进行处理。

(3)创建 github.go,提供 github 认证方法
go

package main



import (

    "context"

    "github.com/golang/glog"

    "github.com/google/go-github/github"

    "golang.org/x/oauth2"

)



func authByGithub(token string) (err error) {

    glog.V(2).Info("start auth by github......")

    tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})

    tokenClient := oauth2.NewClient(context.Background(), tokenSource)

    githubClient := github.NewClient(tokenClient)

    _, _, err = githubClient.Users.Get(context.Background(), "")

    if err != nil {

        return err

    }

    return nil

}

可以看到,这里仅仅做了一个简单的 Token 认证,认证的结果比较粗暴,如果err=nil,则表示认证成功。

(4)创建 ldap.go,提供 ldap 认证
go

package main



import (

    "crypto/tls"

    "errors"

    "fmt"

    "github.com/go-ldap/ldap/v3"

    "github.com/golang/glog"

    "k8s.io/klog/v2"

    "strings"

)



var (

    ldapUrl = "ldap://" + "192.168.100.179:389"

)



func authByLdap(username, password string) error {

    groups, err := getLdapGroups(username, password)

    if err != nil {

        return err

    }

    if len(groups) > 0 {

        return nil

    }



    return fmt.Errorf("No matching group or user attribute. Authentication rejected, Username: %s", username)

}



// 获取user的groups

func getLdapGroups(username, password string) ([]string, error) {

    glog.Info("username:password", username, ":", password)

    var groups []string



    config := &tls.Config{InsecureSkipVerify: true}

    ldapConn, err := ldap.DialURL(ldapUrl, ldap.DialWithTLSConfig(config))

    if err != nil {

        glog.V(4).Info("dial ldap failed, err: ", err)

        return groups, err

    }

    defer ldapConn.Close()



    binduser := fmt.Sprintf("CN=%s,ou=People,dc=demo,dc=com", username)



    err = ldapConn.Bind(binduser, password)

    if err != nil {

        klog.V(4).ErrorS(err, "bind user to ldap error")

        return groups, err

    }



    // 查询用户成员

    searchString := fmt.Sprintf("(&(objectClass=person)(cn=%s))", username)

    memberSearchAttribute := "memberOf"

    searchRequest := ldap.NewSearchRequest(

        "dc=demo,dc=com",

        ldap.ScopeWholeSubtree,

        ldap.NeverDerefAliases,

        0,

        0,

        false,

        searchString,

        []string{memberSearchAttribute},

        nil,

    )

    searchResult, err := ldapConn.Search(searchRequest)

    if err != nil {

        klog.V(4).ErrorS(err, "search user properties error")

        return groups, err

    }

    // 如果没有查到结果,返回失败

    if len(searchResult.Entries[0].Attributes) < 1 {

        return groups, errors.New("no user in ldap")

    }

    entry := searchResult.Entries[0]

    for _, e := range entry.Attributes {

        for _, attr := range e.Values {

            groupList := strings.Split(attr, ",")

            for _, g := range groupList {

                if strings.HasPrefix(g, "cn=") {

                    group := strings.Split(g, "=")

                    groups = append(groups, group[1])

                }

            }

        }

    }

    return groups, nil

}

这里的用户名是固定了的,所以不适合其他场景。

(5)创建 main.go 入口函数
go

package main



import (

    "context"

    "flag"

    "fmt"

    "github.com/golang/glog"

    "net/http"

    "os"

    "os/signal"

    "syscall"

)



var port string



func main() {

    flag.StringVar(&port, "port", "9999", "http server port")

    flag.Parse()

    // 启动httpserver

    wbsrv := WebHookServer{server: &http.Server{

        Addr: fmt.Sprintf(":%v", port),

    }}

    mux := http.NewServeMux()

    mux.HandleFunc("/auth", wbsrv.serve)

    wbsrv.server.Handler = mux



    // 启动协程来处理

    go func() {

        if err := wbsrv.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {

            glog.Errorf("Failed to listen and serve webhook server: %v", err)

        }

    }()



    glog.Info("Server started")



    // 优雅退出

    signalChan := make(chan os.Signal, 1)

    signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

    <-signalChan



    glog.Infof("Got OS shutdown signal, shutting down webhook server gracefully...")

    _ = wbsrv.server.Shutdown(context.Background())

}

到此整个认证服务就开发完毕了,是不是很简单?

Webhook 测试

APIServer 添加认证服务

使用 Webhook 进行认证,需要在 kube-apiserver 里开启,参数如下:

  • --authentication-token-webhook-config-file 指向一个配置文件,其中描述 如何访问远程的 Webhook 服务
  • --authentication-token-webhook-config-file 指向一个配置文件,其中描述 如何访问远程的 Webhook 服务

配置文件使用 kubeconfig 文件的格式。文件中,clusters 指代远程服务,users 指代远程 API 服务 Webhook。配置如下:

(1)、将配置文件放到相应的目录
go

# mkdir /etc/kubernetes/webhook

# cat >> webhook-config.json <EOF

{

  "kind": "Config",

  "apiVersion": "v1",

  "preferences": {},

  "clusters": [

    {

      "name": "github-authn",

      "cluster": {

        "server": "http://10.0.4.9:9999/auth"

      }

    }

  ],

  "users": [

    {

      "name": "authn-apiserver",

      "user": {

        "token": "secret"

      }

    }

  ],

  "contexts": [

    {

      "name": "webhook",

      "context": {

        "cluster": "github-authn",

        "user": "authn-apiserver"

      }

    }

  ],

  "current-context": "webhook"

}

EOF
(2)在 kube-apiserver 中添加配置参数
shell

# mkdir /etc/kubernetes/backup

# cp /etc/kubernetes/manifests/kube-apiserver.yaml /etc/kubernetes/backup/kube-apiserver.yaml

# cd /etc/kubernetes/manifests/

# cat kube-apiserver.yaml

apiVersion: v1

kind: Pod

metadata:

  annotations:

    kubeadm.kubernetes.io/kube-apiserver.advertise-address.endpoint: 10.0.4.9:6443

  creationTimestamp: null

  labels:

    component: kube-apiserver

    tier: control-plane

  name: kube-apiserver

  namespace: kube-system

spec:

  containers:

  - command:

    - kube-apiserver

    - ......

    - --authentication-token-webhook-config-file=/etc/config/webhook-config.json

    image: registry.cn-hangzhou.aliyuncs.com/google_containers/kube-apiserver:v1.22.0

    imagePullPolicy: IfNotPresent

    ......

    volumeMounts:

    ......

    - name: webhook-config

      mountPath: /etc/config

      readOnly: true

  hostNetwork: true

  priorityClassName: system-node-critical

  securityContext:

    seccompProfile:

      type: RuntimeDefault

  volumes:

  ......

  - hostPath:

      path: /etc/kubernetes/webhook

      type: DirectoryOrCreate

    name: webhook-config

status: {}

ps: 为了节约篇幅,上面省略了部分配置。

当修改完过后,kube-apiserver 会自动重启。

测试 Github 认证

(1)在 github 上获取 Token,操作如图所示

17c6ab23eeeed1dd18c89abbbd9e1a90 MD5

(2)配置 kubeconfig,添加 user
go

# cat ~/.kube/config

apiVersion: v1

......

users:

- name: joker

  user:

    token: github:ghp_jevHquU4g43m46nczWS0ojxxxxxxxxx
(3)用 Joker 用户进行访问

返回结果如下,至于报错是因为用户的权限不足。

go

# kubectl get po --user=joker

Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"

可以在 webhook 上看到日志信息,如下:

go

# ./kubernetes-auth-webhook

I1207 15:37:29.531502   21959 webhook.go:55] auth by github success

从日志和结果可以看到,使用 Github 认证是 OK 的。

测试 LDAP 认证

LDAP 简介

LDAP 是协议,不是软件。

LDAP是轻量目录访问协议,英文全称是Lightweight Directory Access Protocol,一般都简称为 LDAP。按照我们对文件目录的理解,ldap 可以看成一个文件系统,类似目录和文件树。

OpenLDAP 是常用的服务之一,也是我们本次测试的认证服务。

安装 OpenLDAP

OpenLDAP的安装方式有很多,可以使用容器部署,也可以直接安装在裸机上,这里采用后者。

go

# yum install -y openldap openldap-clients openldap-servers

# systemctl start slapd

# systemctl enable slapd

默认配置文件,位于/etc/openldap/slapd.d, 文件格式为LDAP Input Format (LDIF), ldap 目录特定的格式。这里不对配置文件做太多的介绍,有兴趣可以自己去学习学习【1】。

在 LDAP 上配置用户
(1)导入模板
go

ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/cosine.ldif

ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/nis.ldif

ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/inetorgperson.ldif
(2)创建 base 组织
go

# cat base.ldif

dn: dc=demo,dc=com

objectClass: top

objectClass: dcObject

objectClass: organization

o: ldap测试组织

dc: demo



dn: cn=Manager,dc=demo,dc=com

objectClass: organizationalRole

cn: Manager

description: 组织管理人



dn: ou=People,dc=demo,dc=com

objectClass: organizationalUnit

ou: People



dn: ou=Group,dc=demo,dc=com

objectClass: organizationalUnit

ou: Group

使用ldapadd添加 base。

go

ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f base.ldif
(3)添加成员
go

# cat adduser.ldif

dn: cn=jack,ou=People,dc=demo,dc=com

changetype: add

objectClass: inetOrgPerson

cn: jack

departmentNumber: 1

title: 大牛

userPassword: 123456

sn: Bai

mail: jack@demo.com

displayName: 中文名

使用 ldapadd 执行添加。

go

ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f adduser.ldif
(4)将用户添加到组
go

# cat add_member_group.ldif

dn: cn=g-admin,ou=Group,dc=demo,dc=com

changetype: modify

add: member

member: cn=jack,ou=People,dc=demo,dc=com

使用 ldapadd 执行添加。

go

ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f add_member_group.ldif

配置 kubeconfig,进行 ldap 认证测试
(1)修改~/.kube/config配置文件
go

# cat ~/.kube/config

apiVersion: v1

......

users:

- name: joker

  user:

    token: github:ghp_jevHquU4g43m46nczWS0oxxxxxxxx

- name: jack

  user:

    token: ldap:jack:123456
(2)使用 kubectl 进行测试
go

# kubectl get po --user=jack

Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"

webhook 服务日志如下:

go

# ./kubernetes-auth-webhook

I1207 16:09:09.292067    7605 webhook.go:72] auth by ldap success

通过测试结果可以看到使用 LDAP 认证测试成功。

总结

使用 Webhook 可以很灵活的将 K8S 的租户和企业内部账户系统进行打通,这样可以方便管理用户账户。

不过上面开发的 Webhook 只是一个简单的例子,验证方式和手法都比较粗暴,CoreOS开源的Dex【2】是比较不错的产品,可以直接使用。

链接

【1】http://www.ldap.org.cn/286.html

【2】https://github.com/dexidp/dex

【3】项目地址:https://gitee.com/coolops/kubernetes-auth-webhook.git

最近更新