Kerberos 简介

Kerberos 是一个由 MIT 开发的一个认证协议,它不仅可以解决互联网上客户端和服务端的认证问题(单点登录),还可以解决服务端与服务端的相互认证问题(比如 api 服务去链接被 kerberos 保护起来的 kafka 或者 ldap)

Kerberos 认证流程

这里放一张的Kerberos的认证流程,流程详见维基百科

预览

简单来说,当一个客户端(客户端可以是终端用户,也可以是某一个服务,比如我们写的backend去链接kafka时,我们的backend就是客户端)需要去申请访问其他服务是,通过 ticket 加一系列的秘钥交换可以获取与 service server 端交互的 Kc-s,随后 Client-Server 使用 Kc-s 进行相互认证的故事。

Kerberos 单点登录原理

不像其他SSO服务,比如 Oauth2,使用重定向才能完成认证,kerberos 需要系统级别的支持。(kerberos 本身不是 HTTP 服务,他需要特定的客户端,比如 kinit 工具来进行登录)

简单来说,当浏览器访问某一个开启 kerberos 保护的网站时,网站的服务端会返回 401 响应并返回一个特殊的 WWW-Authenticate 头,来要求浏览器进行认证。

在 Chromium 内核中(同理firefox),它允许有以下几个值

*   Basic: 1
*   Digest: 2
*   NTLM: 3
*   Negotiate: 4

这些认证方式能够直接被浏览器处理,不需要任何前端就可以进行认证。

其中:

Basic 和 Digest 比较简单,都是通过携带用户名密码在 Http Header 中来完成认证( Digest 传递的是用户名密码的哈希)。

NTLM 是微软搞的一种认证方式,这里不做展开。

Negotiate 则是告诉浏览器要使用 kerberos 进行认证。

因此在服务端开启 kerberos 认证后,如果在认证信息缺失的情况下,它返回的消息中包含这样一个头信息。

WWW-Authenticate: Negotiate

前面说了,kerberos ticket 是存储在系统内部的,无法被浏览器直接访问到。那么浏览器为了能够获取用户的 kerberos ticket(kerberos 凭证),或者是在发现没有凭证时要求用户进行登录,都必须要调用系统相关接口进行获取。

The Generic Security Services Application Program Interface (GSSAPI) 通用安全服务应用程序接口,它允许浏览器或者其他应用程序进行调用,通过现有的 kerberos ticket 来获取用户正在访问的服务的凭证(大概是直接获取流程图中的最后一个 MsgE 和 MsgG 的内容)。从而完成一次 Negotiate 的认证

在 windows 下称为 Simple and Protected GSSAPI Negotiation Mechanism (SPNEGO) ,它要解决的事情和 GSSAPI 相同,可以将他直接看做 GSSAPI 的微软版本。

随后,浏览器会将这个认证信息携带在 Authorization 头中,并标注该凭证为 Negotiate 或者 Kerberos(就和我们如果是 Bearer Token 需要以 Bearer 开头一样),他看起来应该像这样:

Authorization: Negotiate ${KERBEROS_BASE64_CREDENTIAL}

服务端接收到请求后,成功校验 Authorization 后便完成了单点登录。

只要用户进行过 kerberos 的登录,这一切都是由浏览器自动完成的,用户不需要进行任何其他操作。

这无疑是一种更加无缝的单点登录方式。

Kerberos 服务端搭建

安装 krb5 服务端

krb5 是 Kerberos 的第五个版本,这里用Ubuntu做演示,安装方法为:

sudo apt install krb5-kdc krb5-admin-server

安装过程中,会询问我们需要创建的 realm 名称,Realm 表示一个 namespace,下面的资源相互隔离。

这里输入 EXAMPLE.COM (注意全大写)。

安装完成后,输入命令来初始化这个 realm

sudo krb5_newrealm

此时,krb5 将会询问用户一个密码,来作为本地数据库的密码(krb5默认使用DB2存储信息,但是一般会使用LDAP作为krb5的存储后端)

在 krb 中,分为 KDC 和 AS 两个角色,详见维基百科

我们需要对 /etc/krb5.conf 进行一些配置,指定 default_realm,并设置 EXAMPLE.COM 的配置

[libdefaults]
    default_realm = EXAMPLE.COM
    kdc_timesync = 1
    ccache_type = 4
    forwardable = true
    proxiable = true
    debug = true
    dns_lookup_realm = false
    dns_lookup_kdc = false
    fcc-mit-ticketflags = true


[realms]
    # 配置 EXAMPLE.COM 的服务地址
    EXAMPLE.COM = {
        kdc = kdc.example.com
        admin_server = admin.example.com
        default_domain = example.com
    }


[domain_realm]
    # 将 EXAMPLE.COM realm 映射到 example.com 域名
    .example.com = EXAMPLE.COM
    example.com = EXAMPLE.COM

为了能进一步进行测试,我们需要在 /etc/hosts 中增加一条解析记录,将必要的域名都解析到本地

# kerberos
127.0.0.1	    example.com admin.example.com kdc.example.com kerberos.example.com

初始化 Principals

在 Kerberos 中,所有的客户端都被称为 principal(如果是提供服务的 principal 称为 service principal)

为了进行测试,可以创建两个 principal ,一个表示客户的浏览器,一个表示后端服务。

输入 sudo kadmin.local 进入交互界面

预览

输入 addprinc user1 来创建一个 principal,输入 user1 的密码,比如 123456

预览

除了自己输入密码,还可以让 kadmin 自动为我们生成一个密码。

现在,创建一个提供 HTTP 服务的 pricipal,使用 -randkey 来使用随机密码。

输入 addprinc -randkey HTTP/api.example.com@EXAMPLE.COM 来创建一个 principal。

注意,service pricipal 需要遵循一些格式,比如提供 http 服务的就需要使用 HTTP/ 开头,提供 LDAP 服务的就要以 LDAP/ 开头。

预览

既然密码已经随机了,那我们就不能再使用密码进行登录认证了,而是要通过 krb5 的 keytab 来进行认证。

现在使用命令 ktadd -k /root/api.keytab HTTP/api.example.com@EXAMPLE.COM 来生成一个 keytab 文件。

这个 keytab 文件是后面我们HTTP服务要用的,而现在我们模拟用户进行登录,输入kinit -p user1@EXAMPLE.COM 进行登录,输入密码后,登录成功

预览

查看自己有没有登录成功,可以使用 klist 来进行查看

预览

此时系统应该能显示出我们获取的 ticket 以及有效期等信息,使用这个 ticket,我们便可以去和刚才的 HTTP 服务做校验了。

编写支持 Kerberos 认证的  HTTP 服务

这里使用 spring-security-kerberos 这个库来处理 HTTP 服务,引入必要的库

     <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>


        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>


        <dependency>
            <groupId>org.springframework.security.kerberos</groupId>
            <artifactId>spring-security-kerberos-web</artifactId>
            <version>1.0.1.RELEASE</version>
        </dependency>


        <dependency>
            <groupId>org.springframework.security.kerberos</groupId>
            <artifactId>spring-security-kerberos-client</artifactId>
            <version>1.0.1.RELEASE</version>
        </dependency>
    </dependencies>

这里使用2.x版本的 Springboot 和 1.0.1.RELEASE 版本的 kerberos 库。

接着参考以下代码


package com.example.demo;


import java.security.Principal;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;


import javax.servlet.http.HttpServletRequest;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.kerberos.authentication.KerberosAuthenticationProvider;
import org.springframework.security.kerberos.authentication.KerberosServiceAuthenticationProvider;
import org.springframework.security.kerberos.authentication.sun.SunJaasKerberosClient;
import org.springframework.security.kerberos.authentication.sun.SunJaasKerberosTicketValidator;
import org.springframework.security.kerberos.web.authentication.SpnegoAuthenticationProcessingFilter;
import org.springframework.security.kerberos.web.authentication.SpnegoEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;


@Configuration
@RestController
@EnableWebSecurity
public class Config {


    @GetMapping(value = "/home")
    public String home(HttpServletRequest req) {
        UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return "welcome home " + user.getUsername();
    }


    // @Value("${app.service-principal}")
    private String servicePrincipal = "HTTP/api.example.com@EXAMPLE.COM";


    // @Value("${app.keytab-location}")
    private String keytabLocation = "api.keytab";


    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        KerberosAuthenticationProvider kerberosAuthenticationProvider = kerberosAuthenticationProvider();
        KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider = kerberosServiceAuthenticationProvider();
        ProviderManager providerManager = new ProviderManager(kerberosAuthenticationProvider,
                kerberosServiceAuthenticationProvider);


        http
                .authorizeHttpRequests(
                        (authz) -> authz
                                .requestMatchers().permitAll()
                                .anyRequest().authenticated())
                .exceptionHandling(e -> e.authenticationEntryPoint(spnegoEntryPoint()))
                .authenticationProvider(kerberosAuthenticationProvider())
                .authenticationProvider(kerberosServiceAuthenticationProvider())
                .addFilterBefore(spnegoAuthenticationProcessingFilter(providerManager),
                        BasicAuthenticationFilter.class);
        return http.build();
    }


    @Bean
    KerberosAuthenticationProvider kerberosAuthenticationProvider() {
        KerberosAuthenticationProvider provider = new KerberosAuthenticationProvider();
        SunJaasKerberosClient client = new SunJaasKerberosClient();
        client.setDebug(true);
        provider.setKerberosClient(client);
        provider.setUserDetailsService(dummyUserDetailsService());
        return provider;
    }


    @Bean
    SpnegoEntryPoint spnegoEntryPoint() {
        return new SpnegoEntryPoint();
    }


    SpnegoAuthenticationProcessingFilter spnegoAuthenticationProcessingFilter(
            AuthenticationManager authenticationManager) {
        SpnegoAuthenticationProcessingFilter filter = new SpnegoAuthenticationProcessingFilter();
        filter.setAuthenticationManager(authenticationManager);
        return filter;
    }


    @Bean
    KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider() {
        KerberosServiceAuthenticationProvider provider = new KerberosServiceAuthenticationProvider();
        provider.setTicketValidator(sunJaasKerberosTicketValidator());
        provider.setUserDetailsService(dummyUserDetailsService());
        return provider;
    }


    @Bean
    SunJaasKerberosTicketValidator sunJaasKerberosTicketValidator() {
        SunJaasKerberosTicketValidator ticketValidator = new SunJaasKerberosTicketValidator();
        ticketValidator.setServicePrincipal(servicePrincipal);
        ticketValidator.setKeyTabLocation(new ClassPathResource(keytabLocation));
        ticketValidator.setDebug(true);
        return ticketValidator;
    }


    @Bean
    UserDetailsService dummyUserDetailsService() {
        return new UserDetailsService() {


            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                return new User(username);
            }


        };
    }


    static class User implements UserDetails {
        private String username;


        public User(String username) {
            this.username = username;
        }


        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return Collections.singleton(new SimpleGrantedAuthority("USER"));
        }


        @Override
        public String getPassword() {
            return null;
        }


        @Override
        public String getUsername() {
            return username;
        }


        @Override
        public boolean isAccountNonExpired() {
            return true;
        }


        @Override
        public boolean isAccountNonLocked() {
            return true;
        }


        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }


        @Override
        public boolean isEnabled() {
            return true;
        }
    }


}

我们定义了一个 /home 的路由,让他返回 welcome home 的信息,同时定义了 SecurityFilterChain 并使用 KerberosServiceAuthenticationProvider 进行认证。

注意 servicePrincipal 变量指向了刚才创建的 HTTP service principal。

同时,将刚才创建的 api.keytab 文件拷贝到 Spring 项目的 resources 文件夹下。注意!!api.keytab 文件的权限必须是 600 的(只有当前用户有读权限)。

我们模拟 api 服务跑在 api.example.com 的域名下,因此在 /etc/hosts 下添加这条记录

# kerberos
127.0.0.1	    api.example.com

启动 springboot 后,应该可以 curl 一下试试

curl api.example.com:8080 -vvv

不出意外,服务端应该会返回一个 Negotiate 的 WWW-Authenticate 头

预览

注意我这里将 api.example.com 解析到了另外一台机器上,模拟了单点登录中的多台机器的情形,但使用同一台机器做测试也没问题

curl 也可以直接启用 negotiate 认证,只需要输入

curl --negotiate -u: http://ldap.example.com:8080/home -vvv

如果一切顺利,curl 就能自动获取认证信息

预览

Linux 下配置浏览器使用 Kerberos 认证

不同系统,不同浏览器配置 kerberos 的认证方式都不一样。

先说比较简单的 Linux。

注意,这一部分一般情况下不会由项目同学自己搭建,一般情况下这些基础环境都已经由客户方的运维搞定了,这里只做介绍,有兴趣的同学可以参考。

Firefox

对于火狐,需要删除系统自带的版本(snap version),再下载官网的版本。

打开火狐后,在地址栏输入 about:config ,进入配置页面,搜索 network.nego 后,修改如下两项,将 example.com 添加到认证白名单中

预览

由于 Firefox 内置了 gssapilib,因此它直接能处理 Negotiate 的认证。

配置完后,重启 Firefox,在新的标签也中打开并输入网址 http://api.example.com:8080/home

此时应该能看到服务端的正常响应

预览

Chrome

Chrome 配置 kerberos 稍微复杂一些。

首先需要安装 gsslib 

sudo apt install libgss-dev

随后需要配置 chrome 的 polices,要允许 chrome 使用系统自带的 gssapi 进行认证,同时要配置允许认证的白名单。

在 /etc/opt/chrome/policies/managed 目录下,新建一个 example.json 的文件,内容如下

{
  "AuthServerAllowlist": "*.example.com*",
  "AuthNegotiateDelegateAllowlist": "*.example.com*",
  "DisableAuthNegotiateCnameLookup" : true,
  "EnableAuthNegotiatePort" :false ,  
  "GSSAPILibraryName": "libgssapi_krb5.so.2"
}

注意,在旧版本的 chrome 中,AuthServerAllowlist 和 AuthNegotiateDelegateAllowlist 名为 AuthServerWhitelist 和 AuthNegotiateDelegateWhitelist。可能是因为美国那边避讳了 black 和 white 的缘故…

如果配置正常,打开 chrome://policy 后应该能够看到自定义过的所有 Policies,如下:

预览

如果在 Status 一列中显示的不是 OK,而是 Invalid 或者 Error,那说明可能拼写错误,或者是 Policy 改名了,或者是 value 不正确,可以查看 chrome 自带的 policy 目录。

此时再打开 http://api.example.com:8080/home , 就能够正常访问了:

预览

Chrome 对 Negotiate 的认证是对用户屏蔽的,因此你在控制台找不到该认证的请求。

Windwos 下配置浏览器使用 Kerberos 认证

由于 Windows 下需要 Domain Controller 来发起一个 SPNEGO 认证,需要搭建 windows server + Active Directory + LDAP,并且要将电脑加入这个域中,稍微有点复杂。

暂时跳过这部分的配置,一般情况下,客户那边应该由运维搭建好基础环境,因此我们在实际工作中只要理解 SPNEGO 的认证原理即可。

参考资料