K8S源码分析4:用户认证机制

K8S源码分析4:用户认证机制

对于认证机制,本文更追求完备、整体、全面的认知。
所以在分析K8s的认证-权限管理机制之前,会有相当的篇幅去介绍目前普适的一些认证机制原理,作为铺垫和前言。

微服务化的大背景下,API交互错综复杂,权限认证机制对于微服务架构来说不是锦上添花,而是构建稳健微服务系统的基石。

作为一个大规模应用的微服务应用软件,K8s对于用户认证和权限管理系统的构建经验,值得我们深入学习和借鉴。

常见认证机制

目前Web开发中,认证机制有如下几种

1. HTTP Basic Auth

用户名和密码直接带进HTTP请求中。这是最简单的认证方式。
优点: 简单,容易实现。
缺点: 容易被拦截+破解。而且对于用户的登出、失效无法做到及时的判断。

2. OAUTH

开放授权(OAuth)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

举例,有时候我们需要在第三方应用上使用微信、支付宝账户的信息,这时,就是利用的OAUTH协议的机制。

目前已发展到OAUTH2.1版本,提供各类语言的Demo,使用简单、安全、开放,也是目前广大应用、移动开发中最常见的认证方式。

基本的流程:

  1. 用户打开客户端,客户端要求用户授权
  2. 用户同意授权
  3. 客户端根据用户授权,向认证服务器申请令牌
  4. 认证服务器对客户端进行认证,确认无误,发访令牌
  5. 客户端使用令牌,向资源服务器申请获取资源
  6. 资源服务器确认令牌无误,同意向客户端开放资源

四大角色
由授权流程图中可以看到 OAuth 2.0 有四个角色:

  1. 客户端:客户端是代表资源所有者对资源服务器发出访问受保护资源请求的应用程序
  2. 资源拥有者:资源拥有者是对资源具有授权能力的人。
  3. 资源服务器:资源所在的服务器。
  4. 授权服务器:为客户端应用程序提供不同的 Token,可以和资源服务器在统一服务器上,也可以独立出去。

OAuth 2.0 定义了四种授权方式:authorizationcode、implicit、resource owner password credentials、client credentials
这里不做延申。

3. Cookie AUTH

Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookie 的expire time使cookie在一定时间内有效。

Cookie认证存在一些细节问题:Cookie在设计时,是禁止跨域的,申请信息必须同源等等。

长期以来,Cookie机制给了广告厂商一个手段来追踪用户。例如如果是你正常的正在逛着天猫,天猫会把你的信息写入一些 Cookie 到 .tmall.com 这个域下,然而打开控制台你会看到,并不是所有 Cookie 都是 .tmall.com 这个域下的,里面还有很多其他域下的 Cookie ,这些所有非当前域下的 Cookie 都属于第三方 Cookie,虽然你可能从来没访问过这些域,但是他们已经悄悄的通过这些第三方 Cookie来标识你的信息,然后把你的个人信息发送过去了。Chrome和Safari已经开始逐步禁止第三方的Cookie。对广告厂商将有不小的影响。
详细可以看这篇介绍

4. Token Auth

使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。

大概的流程是这样的:

  1. 客户端使用用户名跟密码请求登录
  2. 服务端收到请求,去验证用户名与密码
  3. 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
  4. 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
  6. 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据

ce3be33cf73c69f0919bb0fbdcee099c.png

Token Auth的优势:

  1. 支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
  2. 无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
  3. 更适用CDN: 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.
  4. 去耦: 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以Token生成调用即可.
  5. 更适用于移动应用: 当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
  6. CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。
  7. 性能: 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多.
  8. 不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
  9. 基于标准化:你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby,Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).

微服务下用户认证的方案

微服务相比单体应用,需要考虑跨服务之间认证信息的同步。我们通常使用单点登录(SSO, Single Sign-On)这个词来描述这个问题:一个地方登录,其他服务可以共享登录信息。关于SSO,可以看这篇文章

类似的,还有单点登出(
SSOff, Single Sign-Off)
或者一次登出,部分登出,而是否全部登出或部分登出取决于用户的选择,例如用户在 Web 端登出后,是否无线端 APP 也登出,这取决于用户偏好,但系统应当提供这种能力。

这类系统需要的,是统一身份管理系统(UIMS),进行全局的身份管理。实现这样的效果,目前可以有两类技术方案:

  1. Cookie+Session的有状态会话模式
  2. 基于令牌的无状态交互模式

这里有没有状态的区别就是服务器端是否存储用户会话信息。
具体来说,微服务的Session管理就是分布式Session服务,例如用Redis对Session进行管理。
无状态的模式主要有:

  1. OAUTH
  2. JWT + API网关

关于JWT和OAUTH的应用,网上有很多。后面有时间,会再用别的文章进行详细的展开。

K8s的用户认证方式

在K8s中,有两种用户:

  1. 由K8s管理的服务账号
  2. 普通用户

kubernetes假定普通用户是由一个与集群无关的服务,通过以下方式之一进行管理的:

  • 负责分发私钥的管理员
  • 类似keystone或者Google Accounts这类用户数据库
  • 包含用户名和密码列表的文件

尽管无法通过 API 调用来添加普通用户,Kubernetes 仍然认为能够提供由集群的证书 机构签名的合法证书的用户是通过身份认证的用户。基于这样的配置,Kubernetes 使用证书中的 'subject' 的通用名称(Common Name)字段(例如,"/CN=bob")来 确定用户名。接下来,基于角色访问控制(RBAC)子系统会确定用户是否有权针对 某资源执行特定的操作。进一步的细节可参阅 证书请求 下普通用户主题。

而服务账号则可以理解为是一种资源,由k8s进行管理,可以借由API进行创建。它们被绑定到特定的名字空间, 或者由 API 服务器自动创建,或者通过 API 调用创建。服务账号与一组以 Secret 保存 的凭据相关,这些凭据会被挂载到 Pod 中,从而允许集群内的进程访问 Kubernetes API。

普通用户的来源比较多元,所以用户认证的方式也略微复杂;体现在,k8s可以通过多种身份认证插件来进行用户认证,包括:

  1. 客户端证书
  2. 持有者令牌 bearer token
  3. 身份认证代理 proxy
  4. http基本认证机制

通常一个API请求中,会有一些字段和用户的身份相关联,但是不同的认证插件,处理的字段不同,特定字段只有被特定插件获取到时,才有意义。
这些属性字段包括

  • 用户名:用来辩识最终用户的字符串。常见的值可以是 kube-admin 或 jane@example.com。
  • 用户 ID:用来辩识最终用户的字符串,旨在比用户名有更好的一致性和唯一性。
  • 用户组:取值为一组字符串,其中各个字符串用来标明用户是某个命名的用户逻辑集合的成员。 常见的值可能是 system:masters 或者 devops-team 等。
  • 附加字段:一组额外的键-值映射,键是字符串,值是一组字符串;用来保存一些鉴权组件可能 觉得有用的额外信息。

你可以同时启用多种身份认证方法,认证过程中,API Server会遍历每一个认证方式,一旦有一个能识别出用户信息的认证方法,就会结束这一过程。

对于所有通过身份认证的用户,system:authenticated 组都会被添加到其组列表中。
与其它身份认证协议(LDAP、SAML、Kerberos、X509 的替代模式等等)都可以通过 使用一个身份认证代理或 身份认证 Webhoook来实现。

详细见官方文档

K8s的权限认证略复杂,我们上面提到的JWT和OAUTH2都有应用,除此以外,还包含服务账号的伪装机制、通过web hook调取第三方认证服务进行认证的机制。

K8s用户认证的实现分析

kube-apiserver目前提供了8种认证机制,分别是ClientCA、TokenAuth、BootstrapToken、RequestHeader、WebhookTokenAuth、Anonymous、OIDC、ServiceAccountAuth

认证器汇聚入口

认证器在实现上,属于Kube-apiserver的一部分。
一个Https请求到达ApiServer后,ApiServer在正式响应请求前,会有一个环节,遍历内部所有的认证器,从当前的请求种提取到用户信息,如果无法通过任何一个认证器的身份认证,该请求不会走到后续的流程中。

ApiServer内置的认证器列表可以参见如下代码

// 文件地址 pkg/kubeapiserver/options/authentication.go
// api-server 所有内置认证器所需要的属性项
type BuiltInAuthenticationOptions struct {     
   APIAudiences    []string     

   Anonymous       *AnonymousAuthenticationOptions     
   BootstrapToken  *BootstrapTokenAuthenticationOptions     
   ClientCert      *genericoptions.ClientCertAuthenticationOptions     
   OIDC            *OIDCAuthenticationOptions     
   RequestHeader   *genericoptions.RequestHeaderAuthenticationOptions    
   ServiceAccounts *ServiceAccountAuthenticationOptions     
   TokenFile       *TokenFileAuthenticationOptions     
   WebHook         *WebHookAuthenticationOptions   

   TokenSuccessCacheTTL time.Duration     
   TokenFailureCacheTTL time.Duration
}

除了最后两个设置是通用的,其他的几个Options都是针对特定认证器的设置。
仔细数数看,有这几个认证器:

  • Anonymous
  • BootstrapToken
  • ClientCert
  • OIDC
  • RequestHeader
  • ServiceAccount
  • TokenFile
  • WebHook

一共八种,之前还有http的basic auth认证器,后来1.16版本之后就逐渐废弃了。

K8s的实现里,大量使用了代理模式。例如Api Server的工作,实际处理可能是由底层的Config代理类来完成的。配置的初始化与启动工作将会在未来zhenduiapi server的分析中另外描述。

BuiltInAuthenticationOptions这个类,会通过类似构建者模式的方式初始化所有内置认证器的默认配置:

// WithAll set default value for every build-in authentication option
func (o *BuiltInAuthenticationOptions) WithAll() *BuiltInAuthenticationOptions {
    return o.
        WithAnonymous().
        WithBootstrapToken().
        WithClientCert().
        WithOIDC().
        WithRequestHeader().
        WithServiceAccounts().
        WithTokenFile().
        WithWebHook()
}

// WithAnonymous set default value for anonymous authentication
func (o *BuiltInAuthenticationOptions) WithAnonymous() *BuiltInAuthenticationOptions {
    o.Anonymous = &AnonymousAuthenticationOptions{Allow: true}
    return o
}
// 省略其他认证器配置的构造
.....

不过这里只是默认配置的初始化,在API Server的后续启动流程里会解析实际接收到的命令行参数,再根据命令行参数的设置,把真正需要使用的认证器给实例化。

最终Api Server其实使用的是unionAuthRequestHandler来对权限进行校验,该类可以看作是一个权限认证器的装饰类“

// unionAuthRequestHandler authenticates requests using a chain of authenticator.Requeststype 
unionAuthRequestHandler struct {     
    // Handlers is a chain of request authenticators to  delegate to     
    Handlers []authenticator.Request     
    // FailOnError determines whether an error returns 
    short-circuits the chain     FailOnError bool
}

func (authHandler *unionAuthRequestHandler) AuthenticateRequest(req *http.Request) (*authenticator.Response, 
bool, error) {     
    var errlist []error    
    for _, currAuthRequestHandler := range authHandler.Handlers {           
        resp, ok, err := currAuthRequestHandler.AuthenticateRequest(req)           
        if err != nil {               
             if authHandler.FailOnError {                     
                return resp, ok, err                
             }                
             errlist = append(errlist, err)                
            continue           
        }           
        if ok {                
            return resp, ok, err           
        }     
    }     
    return nil, false, utilerrors.NewAggregate(errlist)
}

在auth.AuthenticateRequest函数对请求进行认证的过程中,遍历已启用的认证器列表并执行每个认证器。

内置认证器1:ClientCA认证

客户端认证授权机制,可以通过 --client-ca-file=SOMEFILE参数启用。这里设置的文件必须包括一个或者多个
证书机构,用来验证向 API 服务器提供的客户端证书。
如果提供了客户端证书并且证书被验证通过,则 subject 中的公共名称(Common Name)就被 作为请求的用户名。 自 Kubernetes 1.4 开始,客户端证书还可以通过证书的 organization 字段标明用户的组成员信息。 要包含用户的多个组成员信息,可以在证书种包含多个 organization 字段。

例如,使用 openssl 命令行工具生成一个证书签名请求:


openssl req -new -key jbeda.pem -out jbeda-csr.pem -subj "/CN=jbeda/O=app1/O=app2"

ClientCA认证器实现了接口:

// 文件 vendor/k8s.io/apiserver/pkg/authentication/authenticator/interfaces.go
// Request attempts to extract authentication information from a request and
// returns a Response or an error if the request could not be  checked.
type Request interface {    
    AuthenticateRequest(req *http.Request) (*Response, bool, 
error)}

该方法接收客户端请求。若验证失败,bool值会为false;若验证成功,bool值会为true,并返回authenticator.Response,authenticator.Response中携带了身份验证用户的信息,例如Name、UID、Groups、Extra等信息。

实现如下

// 文件位置  vendor/k8s.io/apiserver/pkg/authentication/request/x509/x509.go
// AuthenticateRequest authenticates the request using presented client certificates
func (a *Authenticator) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) {
    if req.TLS == nil || len(req.TLS.PeerCertificates) == 0 {
        return nil, false, nil
    }

    // Use intermediates, if provided
    optsCopy, ok := a.verifyOptionsFn()
    // if there are intentionally no verify options, then we cannot authenticate this request
    if !ok {
        return nil, false, nil
    }
    if optsCopy.Intermediates == nil && len(req.TLS.PeerCertificates) > 1 {
        optsCopy.Intermediates = x509.NewCertPool()
        for _, intermediate := range req.TLS.PeerCertificates[1:] {
            optsCopy.Intermediates.AddCert(intermediate)
        }
    }

    remaining := req.TLS.PeerCertificates[0].NotAfter.Sub(time.Now())
    clientCertificateExpirationHistogram.Observe(remaining.Seconds())
    chains, err := req.TLS.PeerCertificates[0].Verify(optsCopy)
    if err != nil {
        return nil, false, fmt.Errorf(
            "verifying certificate %s failed: %w",
            certificateIdentifier(req.TLS.PeerCertificates[0]),
            err,
        )
    }

    var errlist []error
    for _, chain := range chains {
        user, ok, err := a.user.User(chain)
        if err != nil {
            errlist = append(errlist, err)
            continue
        }

        if ok {
            return user, ok, err
        }
    }
    return nil, false, utilerrors.NewAggregate(errlist)
}

在进行ClientCA认证时,通过req.TLS.PeerCertificates[0].Verify验证证书,如果是CA签名过的证书,都可以通过验证,认证失败会返回false,而认证成功会返回true。

内置认证器2: TokenAuth

当 API 服务器的命令行设置了 --token-auth-file=SOMEFILE 选项时,会从文件中 读取持有者令牌。目前,令牌会长期有效,并且在不重启 API 服务器的情况下 无法更改令牌列表。令牌文件是一个 CSV 文件,包含至少 3 个列:令牌、用户名和用户的 UID。 其余列被视为可选的组名。

当使用持有者令牌来对某 HTTP 客户端执行身份认证时,API 服务器希望看到 一个名为 Authorization 的 HTTP 头,其值格式为 Bearer THETOKEN。 持有者令牌必须是一个可以放入 HTTP 头部值字段的字符序列,至多可使用 HTTP 的编码和引用机制。 例如:如果持有者令牌为 31ada4fd-adec-460c-809a-9e56ceb75269,则其 出现在 HTTP 头部时如下所示:

Authorization: Bearer 31ada4fd-adec-460c-809a-9e56ceb75269

令牌认证器的接口定义;

// 文件路径 vendor/k8s.io/apiserver/pkg/authentication/authenticator/interfaces.go
// Token checks a string value against a backing authentication store and
// returns a Response or an error if the token could not be checked.
type Token interface {
    AuthenticateToken(ctx context.Context, token string) (*Response, bool, error)
}

具体实现

// 文件 staging/src/k8s.io/apiserver/pkg/authentication/token/tokenfile/tokenfile.go
func (a *TokenAuthenticator) AuthenticateToken(ctx context.Context, value string) (*authenticator.Response, bool, error) {
    user, ok := a.tokens[value]
    if !ok {
        return nil, false, nil
    }
    return &authenticator.Response{User: user}, true, nil
}

在初始化的过程中,已经从指定的csv文件中解析出了用户信息,所以这里的认证环节非常简单,一目了然。

内置认证器3: BooststrapToken

为了支持平滑地启动引导新的集群,Kubernetes 包含了一种动态管理的持有者令牌类型, 称作 启动引导令牌(Bootstrap Token)。 这些令牌以 Secret 的形式保存在 kube-system 名字空间中,可以被动态管理和创建。 控制器管理器包含的 TokenCleaner 控制器能够在启动引导令牌过期时将其删除。

这些令牌的格式为 [a-z0-9]{6}.[a-z0-9]{16}。第一个部分是令牌的 ID;第二个部分 是令牌的 Secret。你可以用如下所示的方式来在 HTTP 头部设置令牌:


Authorization: Bearer 781292.db7bc3a58fc5f07e

同样都是令牌认证,区别在于,TokenFile是指定了令牌所在的文件,而引导令牌则是自动生成的、存储在k8s的资源,可以动态的从特定命名空间中获取。
可以摘取实现看一下:


// 文件 plugin/pkg/auth/authenticator/token/bootstrap/bootstrap.go
// AuthenticateToken tries to match the provided token to a bootstrap token secret
// in a given namespace. If found, it authenticates the token in the
// "system:bootstrappers" group and with the "system:bootstrap:(token-id)" username.
//
// All secrets must be of type "bootstrap.kubernetes.io/token". An example secret:
//
//     apiVersion: v1
//     kind: Secret
//     metadata:
//       # Name MUST be of form "bootstrap-token-( token id )".
//       name: bootstrap-token-( token id )
//       namespace: kube-system
//     # Only secrets of this type will be evaluated.
//     type: bootstrap.kubernetes.io/token
//     data:
//       token-secret: ( private part of token )
//       token-id: ( token id )
//       # Required key usage.
//       usage-bootstrap-authentication: true
//       auth-extra-groups: "system:bootstrappers:custom-group1,system:bootstrappers:custom-group2"
//       # May also contain an expiry.
//
// Tokens are expected to be of the form:
//
//     ( token-id ).( token-secret )
//
func (t *TokenAuthenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
    tokenID, tokenSecret, err := bootstraptokenutil.ParseToken(token)
    if err != nil {
        // Token isn't of the correct form, ignore it.
        return nil, false, nil
    }

    secretName := bootstrapapi.BootstrapTokenSecretPrefix + tokenID
    secret, err := t.lister.Get(secretName)
    if err != nil {
        if errors.IsNotFound(err) {
            klog.V(3).Infof("No secret of name %s to match bootstrap bearer token", secretName)
            return nil, false, nil
        }
        return nil, false, err
    }

    if secret.DeletionTimestamp != nil {
        tokenErrorf(secret, "is deleted and awaiting removal")
        return nil, false, nil
    }

    if string(secret.Type) != string(bootstrapapi.SecretTypeBootstrapToken) || secret.Data == nil {
        tokenErrorf(secret, "has invalid type, expected %s.", bootstrapapi.SecretTypeBootstrapToken)
        return nil, false, nil
    }

    ts := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenSecretKey)
    if subtle.ConstantTimeCompare([]byte(ts), []byte(tokenSecret)) != 1 {
        tokenErrorf(secret, "has invalid value for key %s, expected %s.", bootstrapapi.BootstrapTokenSecretKey, tokenSecret)
        return nil, false, nil
    }

    id := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenIDKey)
    if id != tokenID {
        tokenErrorf(secret, "has invalid value for key %s, expected %s.", bootstrapapi.BootstrapTokenIDKey, tokenID)
        return nil, false, nil
    }

    if bootstrapsecretutil.HasExpired(secret, time.Now()) {
        // logging done in isSecretExpired method.
        return nil, false, nil
    }

    if bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenUsageAuthentication) != "true" {
        tokenErrorf(secret, "not marked %s=true.", bootstrapapi.BootstrapTokenUsageAuthentication)
        return nil, false, nil
    }

    groups, err := bootstrapsecretutil.GetGroups(secret)
    if err != nil {
        tokenErrorf(secret, "has invalid value for key %s: %v.", bootstrapapi.BootstrapTokenExtraGroupsKey, err)
        return nil, false, nil
    }

    return &authenticator.Response{
        User: &user.DefaultInfo{
            Name:   bootstrapapi.BootstrapUserPrefix + string(id),
            Groups: groups,
        },
    }, true, nil
}

内置认证器4:身份认证代理(RequestHeader)

API 服务器可以配置成从请求的头部字段值(如 X-Remote-User)中辩识用户。
这一设计是用来与某身份认证代理一起使用 API 服务器,代理负责设置请求的头部字段值。

  • --requestheader-username-headers 必需字段,大小写不敏感。用来设置要获得用户身份所要检查的头部字段名称列表(有序)。第一个包含数值的字段会被用来提取用户名。
  • --requestheader-group-headers可选字段,在 Kubernetes 1.6 版本以后支持,大小写不敏感。 建议设置为 "X-Remote-Group"。用来指定一组头部字段名称列表,以供检查用户所属的组名称。 所找到的全部头部字段的取值都会被用作用户组名。
  • --requestheader-extra-headers-prefix 可选字段,在 Kubernetes 1.6 版本以后支持,大小写不敏感。 建议设置为 "X-Remote-Extra-"。用来设置一个头部字段的前缀字符串,API 服务器会基于所给 前缀来查找与用户有关的一些额外信息。这些额外信息通常用于所配置的鉴权插件。 API 服务器会将与所给前缀匹配的头部字段过滤出来,去掉其前缀部分,将剩余部分 转换为小写字符串并在必要时执行百分号解码 后,构造新的附加信息字段键名。原来的头部字段值直接作为附加信息字段的值。

实现如下:

// 文件 staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader.go
func (a *requestHeaderAuthRequestHandler) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) {
    name := headerValue(req.Header, a.nameHeaders.Value())
    if len(name) == 0 {
        return nil, false, nil
    }
    groups := allHeaderValues(req.Header, a.groupHeaders.Value())
    extra := newExtra(req.Header, a.extraHeaderPrefixes.Value())

    // clear headers used for authentication
    for _, headerName := range a.nameHeaders.Value() {
        req.Header.Del(headerName)
    }
    for _, headerName := range a.groupHeaders.Value() {
        req.Header.Del(headerName)
    }
    for k := range extra {
        for _, prefix := range a.extraHeaderPrefixes.Value() {
            req.Header.Del(prefix + k)
        }
    }

    return &authenticator.Response{
        User: &user.DefaultInfo{
            Name:   name,
            Groups: groups,
            Extra:  extra,
        },
    }, true, nil
}

在进行RequestHeader认证时,通过headerValue函数从请求头中读取所有的用户信息,通过allHeaderValues函数读取所有组的信息,通过newExtra函数读取所有额外的信息。当用户名无法匹配时,则认证失败返回false,反之则认证成功返回true。

内置认证器5: WebHook

Webhook也被称为钩子,是一种基于HTTP协议的回调机制,当客户端发送的认证请求到达kube-apiserver时,kube-apiserver回调钩子方法,将验证信息发送给远程的Webhook服务器进行认证,然后根据Webhook服务器返回的状态码来判断是否认证成功。

Webhook 身份认证是一种用来验证持有者令牌的回调机制。

  • --authentication-token-webhook-config-file 指向一个配置文件,其中描述 如何访问远程的 Webhook 服务。
  • --authentication-token-webhook-cache-ttl 用来设定身份认证决定的缓存时间。 默认时长为 2 分钟。

配置文件使用 kubeconfig 文件的格式。文件中,clusters 指代远程服务,users 指代远程 API 服务 Webhook。
WebhookTokenAuth认证实现:

// 文件 staging/src/k8s.io/apiserver/pkg/authentication/token/cache/cached_token_authenticator.go
// AuthenticateToken implements authenticator.Token
func (a *cachedTokenAuthenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
    record := a.doAuthenticateToken(ctx, token)
    if !record.ok || record.err != nil {
        return nil, false, record.err
    }
    for key, value := range record.annotations {
        audit.AddAuditAnnotation(ctx, key, value)
    }
    return record.resp, true, nil
}

在进行WebhookTokenAuth认证时,首先从缓存中查找是否已有缓存认证,如果有则直接返回,如果没有则通过a.authenticator.AuthenticateToken对远程的Webhook服务器进行验证。请求远程的Webhook服务器,通过w.tokenReview.Create(RESTClient)函数发送Post请求,并在请求体(Body)中携带认证信息。在验证Webhook服务器认证之后,返回的Status.Authenticated字段为true,表示认证成功。

内置认证器6: Anonymous

启用匿名请求支持之后,如果请求没有被已配置的其他身份认证方法拒绝,则被视作匿名请求(Anonymous Requests)
这类请求获得用户名 system:anonymous 和 对应的用户组 system:unauthenticated
例如,在一个配置了令牌身份认证且启用了匿名访问的服务器上,如果请求提供了非法的 持有者令牌,则会返回 401 Unauthorized 错误。 如果请求没有提供持有者令牌,则被视为匿名请求。
在 1.5.1-1.5.x 版本中,匿名访问默认情况下是被禁用的,可以通过为 API 服务器设定 --anonymous-auth=true 来启用。
在 1.6 及之后版本中,如果所使用的鉴权模式不是 AlwaysAllow,则匿名访问默认是被启用的。
从 1.6 版本开始,ABACRBAC 鉴权模块要求对 system:anonymous 用户或者 system:unauthenticated 用户组执行显式的权限判定,所以之前的为用户或用户组赋予访问权限的策略规则都不再包含匿名用户。
匿名认证器实现:

// 文件 vendor/k8s.io/apiserver/pkg/authentication/request/anonymous/anonymous.go
func NewAuthenticator() authenticator.Request {
    return authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) {
        auds, _ := authenticator.AudiencesFrom(req.Context())
        return &authenticator.Response{
            User: &user.DefaultInfo{
                Name:   anonymousUser,
                Groups: []string{unauthenticatedGroup},
            },
            Audiences: auds,
        }, true, nil
    })
}

内置认证器7: OICD

OIDC(OpenID Connect)是一套基于OAuth 2.0协议的轻量级认证规范,其提供了通过API进行身份交互的框架。OIDC认证除了认证请求外,还会标明请求的用户身份(ID Token)。其中Toekn被称为ID Token,此ID Token是JSON WebToken (JWT),具有由服务器签名的相关字段。
OIDC认证流程介绍如下。
(1)Kubernetes用户想访问Kubernetes API Server,先通过认证服务(AuthServer,例如Google Accounts服务)认证自己,得到access_token、id_token和refresh_token。
(2)Kubernetes用户把access_token、id_token和refresh_token配置到客户端应用程序(如kubectl或dashboard工具等)中。
(3)Kubernetes客户端使用Token以用户的身份访问Kubernetes API Server。Kubernetes API Server和Auth Server并没有直接进行交互,而是鉴定客户端发送的Token是否为合法Token。

OIDC的认证流程:
(1)用户登录到身份提供商(即Auth Server,例如Google Accounts服务)。
(2)用户的身份提供商将提供access_token、id_token和refresh_token。
(3)用户使用kubectl工具,通过--token参数指定id_token,或者将id_token写入kubeconfig文件中。
(4)kubectl工具将id_token设置为Authorization的请求头并发送给KubernetesAPI Server。
(5)Kubernetes API Server将通过检查配置文件中指定的证书来确保JWT签名有效。
(6)检查并确保id_token未过期。
(7)检查并确保用户已获得授权。
(8)获得授权后,Kubernetes API Server会响应kubectl工具。
(9)kubectl工具向用户提供反馈。

Kubernetes API Server和Auth Server并没有直接进行交互,而是鉴定客户端发送的Token是否为合法Token。
Token的认证流程:
1)用户登录到身份提供商(即Auth Server,例如Google Accounts服务)。
(2)用户的身份提供商将提供access_token、id_token和refresh_token。
(3)用户使用kubectl工具,通过--token参数指定id_token,或者将id_token写入kubeconfig文件中。
(4)kubectl工具将id_token设置为Authorization的请求头并发送给KubernetesAPI Server。
(5)Kubernetes API Server将通过检查配置文件中指定的证书来确保JWT签名有效。
(6)检查并确保id_token未过期。
(7)检查并确保用户已获得授权。
(8)获得授权后,Kubernetes API Server会响应kubectl工具。
(9)kubectl工具向用户提供反馈。

Kubernetes API Server不与Auth Server交互就能够认证Token的合法性,其关键在于第(5)步,所有JWT Token都由颁发它的Auth Service进行了数字签名,只需在Kubernetes API Server中配置信任的Auth Server的证书,并用它来验证收到的id_token中的签名是否合法,这样就可以验证Token的合法性。使用这种基于PKI的验证机制,在配置完成并进行认证的过程中,Kubernetes API Server无须与Auth Server有任何交互。

OIDC的启用与设置参数,详情可以见官网。

OIDC认证器的实现:

// 文件 staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go
func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
    if !hasCorrectIssuer(a.issuerURL, token) {
        return nil, false, nil
    }

    verifier, ok := a.idTokenVerifier()
    if !ok {
        return nil, false, fmt.Errorf("oidc: authenticator not initialized")
    }

    idToken, err := verifier.Verify(ctx, token)
    if err != nil {
        return nil, false, fmt.Errorf("oidc: verify token: %v", err)
    }

    var c claims
    if err := idToken.Claims(&c); err != nil {
        return nil, false, fmt.Errorf("oidc: parse claims: %v", err)
    }
    if a.resolver != nil {
        if err := a.resolver.expand(c); err != nil {
            return nil, false, fmt.Errorf("oidc: could not expand distributed claims: %v", err)
        }
    }

    var username string
    if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil {
        return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err)
    }

    if a.usernameClaim == "email" {
        // If the email_verified claim is present, ensure the email is valid.
        // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
        if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified {
            var emailVerified bool
            if err := c.unmarshalClaim("email_verified", &emailVerified); err != nil {
                return nil, false, fmt.Errorf("oidc: parse 'email_verified' claim: %v", err)
            }

            // If the email_verified claim is present we have to verify it is set to `true`.
            if !emailVerified {
                return nil, false, fmt.Errorf("oidc: email not verified")
            }
        }
    }

    if a.usernamePrefix != "" {
        username = a.usernamePrefix + username
    }

    info := &user.DefaultInfo{Name: username}
    if a.groupsClaim != "" {
        if _, ok := c[a.groupsClaim]; ok {
            // Some admins want to use string claims like "role" as the group value.
            // Allow the group claim to be a single string instead of an array.
            //
            // See: https://github.com/kubernetes/kubernetes/issues/33290
            var groups stringOrArray
            if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil {
                return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err)
            }
            info.Groups = []string(groups)
        }
    }

    if a.groupsPrefix != "" {
        for i, group := range info.Groups {
            info.Groups[i] = a.groupsPrefix + group
        }
    }

    // check to ensure all required claims are present in the ID token and have matching values.
    for claim, value := range a.requiredClaims {
        if !c.hasClaim(claim) {
            return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
        }

        // NOTE: Only string values are supported as valid required claim values.
        var claimValue string
        if err := c.unmarshalClaim(claim, &claimValue); err != nil {
            return nil, false, fmt.Errorf("oidc: parse claim %s: %v", claim, err)
        }
        if claimValue != value {
            return nil, false, fmt.Errorf("oidc: required claim %s value does not match. Got = %s, want = %s", claim, claimValue, value)
        }
    }

    return &authenticator.Response{User: info}, true, nil
}

内置认证器8: ServiceAccount

ServiceAccountAuth是一种特殊的认证机制,其他认证机制都是处于Kubernetes集群外部而希望访问kube-apiserver组件,而ServiceAccountAuth认证是从Pod资源内部访问kube-apiserver组件,提供给运行在Pod资源中的进程使用,它为Pod资源中的进程提供必要的身份证明,从而获取集群的信息。ServiceAccountAuth认证通过Kubernetes资源的Service Account实现。

在集群外部使用服务账号持有者令牌也是完全合法的,且可用来为长时间运行的、需要与 Kubernetes API 服务器通信的任务创建标识。要手动创建服务账号,可以使用 kubectl create serviceaccount <名称> 命令。此命令会在当前的名字空间中生成一个 服务账号和一个与之关联的 Secret。

所创建的 Secret 中会保存 API 服务器的公开的 CA 证书和一个已签名的 JSON Web 令牌(JWT)。

已签名的 JWT 可以用作持有者令牌,并将被认证为所给的服务账号。 关于如何在请求中包含令牌,请参阅前文。 通常,这些 Secret 数据会被挂载到 Pod 中以便集群内访问 API 服务器时使用, 不过也可以在集群外部使用。服务账号被身份认证后,所确定的用户名为 system:serviceaccount:<名字空间>:<服务账号>, 并被分配到用户组 system:serviceaccounts 和 system:serviceaccounts:<名字空间>。警告:由于服务账号令牌保存在 Secret 对象中,任何能够读取这些 Secret 的用户 都可以被认证为对应的服务账号。在为用户授予访问服务账号的权限时,以及对 Secret 的读权限时,要格外小心。

ServiceAccountAuth认证实现:

// 文件 pkg/serviceaccount/jwt.go
func (j *jwtTokenAuthenticator) AuthenticateToken(ctx context.Context, tokenData string) (*authenticator.Response, bool, error) {
    if !j.hasCorrectIssuer(tokenData) {
        return nil, false, nil
    }

    tok, err := jwt.ParseSigned(tokenData)
    if err != nil {
        return nil, false, nil
    }

    public := &jwt.Claims{}
    private := j.validator.NewPrivateClaims()

    // TODO: Pick the key that has the same key ID as `tok`, if one exists.
    var (
        found   bool
        errlist []error
    )
    for _, key := range j.keys {
        if err := tok.Claims(key, public, private); err != nil {
            errlist = append(errlist, err)
            continue
        }
        found = true
        break
    }

    if !found {
        return nil, false, utilerrors.NewAggregate(errlist)
    }

    tokenAudiences := authenticator.Audiences(public.Audience)
    if len(tokenAudiences) == 0 {
        // only apiserver audiences are allowed for legacy tokens
        audit.AddAuditAnnotation(ctx, "authentication.k8s.io/legacy-token", public.Subject)
        legacyTokensTotal.Inc()
        tokenAudiences = j.implicitAuds
    }

    requestedAudiences, ok := authenticator.AudiencesFrom(ctx)
    if !ok {
        // default to apiserver audiences
        requestedAudiences = j.implicitAuds
    }

    auds := authenticator.Audiences(tokenAudiences).Intersect(requestedAudiences)
    if len(auds) == 0 && len(j.implicitAuds) != 0 {
        return nil, false, fmt.Errorf("token audiences %q is invalid for the target audiences %q", tokenAudiences, requestedAudiences)
    }

    // If we get here, we have a token with a recognized signature and
    // issuer string.
    sa, err := j.validator.Validate(ctx, tokenData, public, private)
    if err != nil {
        return nil, false, err
    }

    return &authenticator.Response{
        User:      sa.UserInfo(),
        Audiences: auds,
    }, true, nil
}

在进行ServiceAccountAuth认证时,通过jwt.ParseSigned函数解析出JWT对象,然后通过j.validator.Validate函数验证签名及Token,验证命名空间是否正确,验证ServiceAccountName、ServiceAccountUID是否存在,验证Token是否失效等。如果验证不合法,则认证失败并返回false;如果验证合法,则认证成功并返回true。最后,如果Token能够通过认证,那么请求的用户名将被设置为system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT),而请求的组名有两个,即system:serviceaccountssystem:serviceaccounts:(NAMESPACE)

总结

随着近几年云服务应用的发展,基于令牌(Token)的认证使用范围也越来越广。对于基于令牌认证通常包含下面几层含义:

  • 令牌是认证用户信息的集合,而不仅仅是一个无意义的ID。
  • 在令牌中已经包含足够多的信息,验证令牌就可以完成用户身份的校验,从而减轻了因为用户验证需要检索数据库的压力,提升了系统性能。
  • 因为令牌是需要服务器进行签名发放的,所以如果令牌通过解码认证,我们就可以认为该令牌所包含的信息是合法有效的。
  • 服务器会通过HTTP头部中的Authorization获取令牌信息并进行检查,并不需要在服务器端存储任何信息。
  • 通过服务器对令牌的检查机制,可以将基于令牌的认证使用在基于浏览器的客户端和移动设备的App或是第三方应用上。
  • 可以支持跨程序调用。基于Cookie是不允许垮域访问的,而令牌则不存在这个问题。

综上所述,基于令牌的认证由于会包含认证用户的相关信息,因此可以通过验证令牌来完成用户身份的校验,完全不同于之前基于会话的认证。因此,基于令牌的这个优点,像T微信、支付宝、微博及GitHub等,都推出了基于令牌的认证服务,用于访问所开放的API及单点登录。

所以可以看到,K8s中,大部分的认证方式,归根结底都是各式各样的令牌认证与解析服务。

发表评论