K8S自定义Webhook实现认证管理

时间:2022-09-08 10:24:40

K8S自定义Webhook实现认证管理

大家好,我是乔克。

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

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

K8S自定义Webhook实现认证管理

客户端(普通账户、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的认证请求,然后调用不同的认证服务进行认证,如下所示。

K8S自定义Webhook实现认证管理

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

Webhook开发

开发环境

K8S自定义Webhook实现认证管理

构建符合规范的Webhook

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

  • URL:https://auth.example.com/auth
  • Method:POST
  • Input参数
  1. {
  2. "apiVersion": "authentication.k8s.io/v1beta1",
  3. "kind": "TokenReview",
  4. "spec": {
  5. "token": "<持有者令牌>"
  6. }
  7. }
  • Output参数

如果成功会返回:

  1. {
  2. "apiVersion": "authentication.k8s.io/v1beta1",
  3. "kind": "TokenReview",
  4. "status": {
  5. "authenticated": true,
  6. "user": {
  7. "username": "janedoe@example.com",
  8. "uid": "42",
  9. "groups": [
  10. "developers",
  11. "qa"
  12. ],
  13. "extra": {
  14. "extrafield1": [
  15. "extravalue1",
  16. "extravalue2"
  17. ]
  18. }
  19. }
  20. }
  21. }

如果不成功,会返回:

  1. {
  2. "apiVersion": "authentication.k8s.io/v1beta1",
  3. "kind": "TokenReview",
  4. "status": {
  5. "authenticated": false
  6. }
  7. }

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

开发认证服务

(1)创建项目并初始化go mod

  1. # mkdir kubernetes-auth-webhook
  2. # cd kubernetes-auth-webhook
  3. # go mod init

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

  1. package main
  2. import (
  3. "encoding/json"
  4. "github.com/golang/glog"
  5. authentication "k8s.io/api/authentication/v1beta1"
  6. "k8s.io/klog/v2"
  7. "net/http"
  8. "strings"
  9. )
  10. type WebHookServer struct {
  11. server *http.Server
  12. }
  13. func (ctx *WebHookServer) serve(w http.ResponseWriter, r *http.Request) {
  14. // 从APIServer中取出body
  15. // 将body进行拆分, 取出type
  16. // 根据type, 取出不同的认证数据
  17. var req authentication.TokenReview
  18. decoder := json.NewDecoder(r.Body)
  19. err := decoder.Decode(&req)
  20. if err != nil {
  21. klog.Error(err, "decoder request body error.")
  22. req.Status = authentication.TokenReviewStatus{Authenticated: false}
  23. w.WriteHeader(http.StatusUnauthorized)
  24. _ = json.NewEncoder(w).Encode(req)
  25. return
  26. }
  27. // 判断token是否包含':'
  28. // 如果不包含,则返回认证失败
  29. if !(strings.Contains(req.Spec.Token, ":")) {
  30. klog.Error(err, "token invalied.")
  31. req.Status = authentication.TokenReviewStatus{Authenticated: false}
  32. //req.Status = map[string]interface{}{"authenticated": false}
  33. w.WriteHeader(http.StatusUnauthorized)
  34. _ = json.NewEncoder(w).Encode(req)
  35. return
  36. }
  37. // split token, 获取type
  38. tokenSlice := strings.SplitN(req.Spec.Token, ":", -1)
  39. glog.Infof("tokenSlice: ", tokenSlice)
  40. hookType := tokenSlice[0]
  41. switch hookType {
  42. case "github":
  43. githubToken := tokenSlice[1]
  44. err := authByGithub(githubToken)
  45. if err != nil {
  46. klog.Error(err, "auth by github error")
  47. req.Status = authentication.TokenReviewStatus{Authenticated: false}
  48. w.WriteHeader(http.StatusUnauthorized)
  49. _ = json.NewEncoder(w).Encode(req)
  50. return
  51. }
  52. klog.Info("auth by github success")
  53. req.Status = authentication.TokenReviewStatus{Authenticated: true}
  54. w.WriteHeader(http.StatusOK)
  55. _ = json.NewEncoder(w).Encode(req)
  56. return
  57. case "ldap":
  58. username := tokenSlice[1]
  59. password := tokenSlice[2]
  60. err := authByLdap(username, password)
  61. if err != nil {
  62. klog.Error(err, "auth by ldap error")
  63. req.Status = authentication.TokenReviewStatus{Authenticated: false}
  64. //req.Status = map[string]interface{}{"authenticated": false}
  65. w.WriteHeader(http.StatusUnauthorized)
  66. _ = json.NewEncoder(w).Encode(req)
  67. return
  68. }
  69. klog.Info("auth by ldap success")
  70. req.Status = authentication.TokenReviewStatus{Authenticated: true}
  71. //req.Status = map[string]interface{}{"authenticated": true}
  72. w.WriteHeader(http.StatusOK)
  73. _ = json.NewEncoder(w).Encode(req)
  74. return
  75. }
  76. }

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

  • Github认证:github:
  • LDAP认证:ldap::

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

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

  1. package main
  2. import (
  3. "context"
  4. "github.com/golang/glog"
  5. "github.com/google/go-github/github"
  6. "golang.org/x/oauth2"
  7. )
  8. func authByGithub(token string) (err error) {
  9. glog.V(2).Info("start auth by github......")
  10. tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
  11. tokenClient := oauth2.NewClient(context.Background(), tokenSource)
  12. githubClient := github.NewClient(tokenClient)
  13. _, _, err = githubClient.Users.Get(context.Background(), "")
  14. if err != nil {
  15. return err
  16. }
  17. return nil
  18. }

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

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

  1. package main
  2. import (
  3. "crypto/tls"
  4. "errors"
  5. "fmt"
  6. "github.com/go-ldap/ldap/v3"
  7. "github.com/golang/glog"
  8. "k8s.io/klog/v2"
  9. "strings"
  10. )
  11. var (
  12. ldapUrl = "ldap://" + "192.168.100.179:389"
  13. )
  14. func authByLdap(username, password string) error {
  15. groups, err := getLdapGroups(username, password)
  16. if err != nil {
  17. return err
  18. }
  19. if len(groups) > 0 {
  20. return nil
  21. }
  22. return fmt.Errorf("No matching group or user attribute. Authentication rejected, Username: %s", username)
  23. }
  24. // 获取user的groups
  25. func getLdapGroups(username, password string) ([]string, error) {
  26. glog.Info("username:password", username, ":", password)
  27. var groups []string
  28. config := &tls.Config{InsecureSkipVerify: true}
  29. ldapConn, err := ldap.DialURL(ldapUrl, ldap.DialWithTLSConfig(config))
  30. if err != nil {
  31. glog.V(4).Info("dial ldap failed, err: ", err)
  32. return groups, err
  33. }
  34. defer ldapConn.Close()
  35. binduser := fmt.Sprintf("CN=%s,ou=People,dc=demo,dc=com", username)
  36. err = ldapConn.Bind(binduser, password)
  37. if err != nil {
  38. klog.V(4).ErrorS(err, "bind user to ldap error")
  39. return groups, err
  40. }
  41. // 查询用户成员
  42. searchString := fmt.Sprintf("(&(objectClass=person)(cn=%s))", username)
  43. memberSearchAttribute := "memberOf"
  44. searchRequest := ldap.NewSearchRequest(
  45. "dc=demo,dc=com",
  46. ldap.ScopeWholeSubtree,
  47. ldap.NeverDerefAliases,
  48. 0,
  49. 0,
  50. false,
  51. searchString,
  52. []string{memberSearchAttribute},
  53. nil,
  54. )
  55. searchResult, err := ldapConn.Search(searchRequest)
  56. if err != nil {
  57. klog.V(4).ErrorS(err, "search user properties error")
  58. return groups, err
  59. }
  60. // 如果没有查到结果,返回失败
  61. if len(searchResult.Entries[0].Attributes) < 1 {
  62. return groups, errors.New("no user in ldap")
  63. }
  64. entry := searchResult.Entries[0]
  65. for _, e := range entry.Attributes {
  66. for _, attr := range e.Values {
  67. groupList := strings.Split(attr, ",")
  68. for _, g := range groupList {
  69. if strings.HasPrefix(g, "cn=") {
  70. group := strings.Split(g, "=")
  71. groups = append(groups, group[1])
  72. }
  73. }
  74. }
  75. }
  76. return groups, nil
  77. }

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

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

  1. package main
  2. import (
  3. "context"
  4. "flag"
  5. "fmt"
  6. "github.com/golang/glog"
  7. "net/http"
  8. "os"
  9. "os/signal"
  10. "syscall"
  11. )
  12. var port string
  13. func main() {
  14. flag.StringVar(&port, "port", "9999", "http server port")
  15. flag.Parse()
  16. // 启动httpserver
  17. wbsrv := WebHookServer{server: &http.Server{
  18. Addr: fmt.Sprintf(":%v", port),
  19. }}
  20. mux := http.NewServeMux()
  21. mux.HandleFunc("/auth", wbsrv.serve)
  22. wbsrv.server.Handler = mux
  23. // 启动协程来处理
  24. go func() {
  25. if err := wbsrv.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  26. glog.Errorf("Failed to listen and serve webhook server: %v", err)
  27. }
  28. }()
  29. glog.Info("Server started")
  30. // 优雅退出
  31. signalChan := make(chan os.Signal, 1)
  32. signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
  33. <-signalChan
  34. glog.Infof("Got OS shutdown signal, shutting down webhook server gracefully...")
  35. _ = wbsrv.server.Shutdown(context.Background())
  36. }

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

Webhook测试

APIServer添加认证服务

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

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

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

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

  1. # mkdir /etc/kubernetes/webhook
  2. # cat >> webhook-config.json
  3. {
  4. "kind": "Config",
  5. "apiVersion": "v1",
  6. "preferences": {},
  7. "clusters": [
  8. {
  9. "name": "github-authn",
  10. "cluster": {
  11. "server": "http://10.0.4.9:9999/auth"
  12. }
  13. }
  14. ],
  15. "users": [
  16. {
  17. "name": "authn-apiserver",
  18. "user": {
  19. "token": "secret"
  20. }
  21. }
  22. ],
  23. "contexts": [
  24. {
  25. "name": "webhook",
  26. "context": {
  27. "cluster": "github-authn",
  28. "user": "authn-apiserver"
  29. }
  30. }
  31. ],
  32. "current-context": "webhook"
  33. }
  34. EOF

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

  1. # mkdir /etc/kubernetes/backup
  2. # cp /etc/kubernetes/manifests/kube-apiserver.yaml /etc/kubernetes/backup/kube-apiserver.yaml
  3. # cd /etc/kubernetes/manifests/
  4. # cat kube-apiserver.yaml
  5. apiVersion: v1
  6. kind: Pod
  7. metadata:
  8. annotations:
  9. kubeadm.kubernetes.io/kube-apiserver.advertise-address.endpoint: 10.0.4.9:6443
  10. creationTimestamp: null
  11. labels:
  12. component: kube-apiserver
  13. tier: control-plane
  14. name: kube-apiserver
  15. namespace: kube-system
  16. spec:
  17. containers:
  18. - command:
  19. - kube-apiserver
  20. - ......
  21. - --authentication-token-webhook-config-file=/etc/config/webhook-config.json
  22. image: registry.cn-hangzhou.aliyuncs.com/google_containers/kube-apiserver:v1.22.0
  23. imagePullPolicy: IfNotPresent
  24. ......
  25. volumeMounts:
  26. ......
  27. - name: webhook-config
  28. mountPath: /etc/config
  29. readOnly: true
  30. hostNetwork: true
  31. priorityClassName: system-node-critical
  32. securityContext:
  33. seccompProfile:
  34. type: RuntimeDefault
  35. volumes:
  36. ......
  37. - hostPath:
  38. path: /etc/kubernetes/webhook
  39. type: DirectoryOrCreate
  40. name: webhook-config
  41. status: {}

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

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

测试Github认证

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

K8S自定义Webhook实现认证管理

(2)配置kubeconfig,添加user

  1. # cat ~/.kube/config
  2. apiVersion: v1
  3. ......
  4. users:
  5. - name: joker
  6. user:
  7. token: github:ghp_jevHquU4g43m46nczWS0ojxxxxxxxxx

(3)用Joker用户进行访问

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

  1. # kubectl get po --user=joker
  2. Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"

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

  1. # ./kubernetes-auth-webhook
  2. 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的安装方式有很多,可以使用容器部署,也可以直接安装在裸机上,这里采用后者。

  1. # yum install -y openldap openldap-clients openldap-servers
  2. # systemctl start slapd
  3. # systemctl enable slapd

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

在LDAP上配置用户

(1)导入模板

  1. ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/cosine.ldif
  2. ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/nis.ldif
  3. ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/inetorgperson.ldif

(2)创建base组织

  1. # cat base.ldif
  2. dn: dc=demo,dc=com
  3. objectClass: top
  4. objectClass: dcObject
  5. objectClass: organization
  6. o: ldap测试组织
  7. dc: demo
  8. dn: cn=Manager,dc=demo,dc=com
  9. objectClass: organizationalRole
  10. cn: Manager
  11. description: 组织管理人
  12. dn: ou=People,dc=demo,dc=com
  13. objectClass: organizationalUnit
  14. ou: People
  15. dn: ou=Group,dc=demo,dc=com
  16. objectClass: organizationalUnit
  17. ou: Group

使用ldapadd添加base。

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

(3)添加成员

  1. # cat adduser.ldif
  2. dn: cn=jack,ou=People,dc=demo,dc=com
  3. changetype: add
  4. objectClass: inetOrgPerson
  5. cn: jack
  6. departmentNumber: 1
  7. title: 大牛
  8. userPassword: 123456
  9. sn: Bai
  10. mail: jack@demo.com
  11. displayName: 中文名

使用ldapadd执行添加。

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

(4)将用户添加到组

  1. # cat add_member_group.ldif
  2. dn: cn=g-admin,ou=Group,dc=demo,dc=com
  3. changetype: modify
  4. add: member
  5. member: cn=jack,ou=People,dc=demo,dc=com

使用ldapadd执行添加。

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

配置kubeconfig,进行ldap认证测试

(1)修改~/.kube/config配置文件

  1. # cat ~/.kube/config
  2. apiVersion: v1
  3. ......
  4. users:
  5. - name: joker
  6. user:
  7. token: github:ghp_jevHquU4g43m46nczWS0oxxxxxxxx
  8. - name: jack
  9. user:
  10. token: ldap:jack:123456

(2)使用kubectl进行测试

  1. # kubectl get po --user=jack
  2. Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"

webhook服务日志如下:

  1. # ./kubernetes-auth-webhook
  2. I1207 16:09:09.292067 7605 webhook.go:72] auth by ldap success

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

总结

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

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

本文转载自微信公众号「运维开发故事」

K8S自定义Webhook实现认证管理

原文链接:https://mp.weixin.qq.com/s/JCIO08FbKAePZ2FBvlvZDw