【Java】使用Spring拦截器实现SPNEGO服务端

使用Spring拦截器实现SPNEGO服务端

P_Chou水冗发布于 今天 02:44

在理清SASL/GSSAPI/Kerberos文中,我们了解到,在java中可以通过Krb5LoginModule模块实现Kerberos登录,以及通过GSSAPI完成校验的基本流程;同时也明白java还定义了一个高层次接口SASL,抽象校验流程。校验流程并没有定义客户端和服务端之间传递“票据”的通信方式,用户可以自己开发合适的通信方案,而SPNEGO协议就是基于http传递票据和校验的标准方案,由微软提出,SPNEGO广泛用于希望集成kerberos认证的http的服务中。本文基于spring interceptor尝试实现一个简易的SPNEGO服务。

准备工作

我们需要准备一些需要的环境和物料。首先我们先回顾一下基于kerberos认证访问服务的整个过程:

【Java】使用Spring拦截器实现SPNEGO服务端

1-2: 客户端首先从KDC中验证得到票据,基于客户端持有的用户名(Principal)和密码(keytab)
3-4: 客户端从KDC上获取要访问的服务(SerivcePrincipal)的票据
5-6: 客户端访问服务(SerivcePrincipal)的时候携带票据,服务端校验票据的合法性

从这个流程可以看到,这是一个保护服务端的流程。也就是说不是谁都能随便访问某服务的,必须是持有合法用户名(Principal)和密码(keytab)的客户端。当然,客户端和服务端的通信还可以再多进一步,即客户端验证服务端的合法性,但这个流程一般是省略的。

从这个流程看,我们需要如下环境和物料:

  • KDC服务器,以及这个服务器的地址信息配置(krb5.conf),用于Krb5LoginModule从KDC处获取票据
  • 一个合法的客户端用户名(Principal)和密码(keytab)
  • 一个合法的服务端SerivcePrincipal,服务端在构建的时候也需要登录SerivcePrincipal,所以SerivcePrincipal对应的keytab也需要

SPNEGO

SPNEGO协议只是在kerberos流程的基础上,将上述的5-6步,通过http的方式定义了。我们可以通过curl来访问基于kerberos认证的http服务,例如:

curl -u : -i -k  --negotiate 'https://192.168.21.134:24148/

其中--negotiate告诉curl支持SPNEGO协议。我们来分析一下curl的流程:

  1. 发送普通http请求
  2. 服务端返回401和WWW-Authentication: Negotiation
  3. curl会初始化gss_context,开始kerberos认证流程:

    • 以当前kinit登录的用户为客户端用户,从kdc处获取票据
    • 以HTTP/[email protected]为SerivcePrincipal,从kdc处获得该服务的访问票据。这里的host是被访问的服务器地址或主机名,DOMAIN为kdc的默认域名。这个规则是curl固定的。所以这就意味两点:1. SerivcePrincipal必须是存在的。2. 服务端也需要在以SerivcePrincipal登录的上下文中验证客户端的票据。
    • 再次发送http请求,生成Authentication: Negotiation xxxxxxx,其中xxxxx为加工以后的base64编码字符串

  4. 服务端验证Authentication: Negotiation xxxxxxx

代码实现

了解了基本原理后,着手开发,spring-security-kerberos这个项目基于spring security框架实现了kerberos认证的服务。受这个项目启发,我们用interceptor实现:

@Override

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

throws Exception {

if (request.getRequestURI().contains("/api/v1/xxl-job")) {

return true;

}

if (!initialized) {

try {

// TODO refreshable

LoginConfig loginConfig = new LoginConfig(

config.getSpnegoAuthConfig().getKeytab(),

config.getSpnegoAuthConfig().getPrincipal(),

Boolean.TRUE.equals(config.getSpnegoAuthConfig().getDebug())

);

Subject sub = new Subject();

loginContext = new LoginContext("", sub, (CallbackHandler)null, loginConfig);

loginContext.login();

} catch (Exception ex) {

logger.error("Failed to initialize GSSAPI context", ex);

loginContext = null;

}

initialized = true;

}

if (loginContext == null) {

// 如果已经初始化过,但是失败了,则放行

return true;

}

String auth = request.getHeader(AUTHORIZATION);

if (auth != null) {

String userName = Subject.doAs(loginContext.getSubject(), new AuthAction(auth.trim()));

if (userName != null) {

logger.debug("Login user by spnego: {}", userName);

return true;

}

}

commence(response);

return false;

}

private void commence(HttpServletResponse response) throws IOException {

response.addHeader("WWW-Authenticate", "Negotiate");

// NOTE: 不能写成sendError,会返回两个WWW-Authenticate: Negotiate。why?

// response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());

response.setStatus(401);

response.flushBuffer();

}

我们知道preHandle会拦截任何一个请求,在这里面我们初始化一次logContext,这里使用的是ServicePrincipal,而且必须是如下形式:

HTTP/<HOST>@<REAL_DOMAIN>

试图获取请求头中的Authorization

String auth = request.getHeader(AUTHORIZATION);

如果存在,就进入验证逻辑,如果不存在或者验证失败,就返回401WWW-Authenticate: Negotiate

以上是代码的基本逻辑,重点是看一下验证逻辑:

public class AuthAction implements PrivilegedExceptionAction<String> {

private String authString;

public AuthAction(String authString) {

this.authString = authString;

}

@Override

public String run() throws Exception {

GSSContext context = GSSManager.getInstance().createContext((GSSCredential)null);

try {

String token = authString.substring("Negotiate".length()).trim();

byte[] kerberosTicket = java.util.Base64.getDecoder().decode(token);

context.acceptSecContext(kerberosTicket, 0, kerberosTicket.length);

return context.getSrcName().toString();

} catch (Exception ex) {

logger.error("Failed to auth token", ex);

return null;

} finally {

context.dispose();

}

}

}

可以清楚的看到,代码是如何解码base64,以及最终调用理清SASL/GSSAPI/Kerberos文中提到的acceptSecContext的。

还有一个技巧,是我们自己实现了LoginConfig类,而不依赖jaas配置文件,因为jdk自带的基于jaas配置文件登录的机制,需要使用System.setProperty配置,会污染全局环境。自定义Configuration类后,可以决定如何将登录的配置信息传递给LoginModule,而不局限于使用全局配置项。受spring-security-kerberos启发,LoginConfig的实现如下:

public class LoginConfig extends Configuration {

private String keyTabLocation;

private String servicePrincipalName;

private boolean debug;

public LoginConfig(String keyTabLocation, String servicePrincipalName, boolean debug) {

this.keyTabLocation = keyTabLocation;

this.servicePrincipalName = servicePrincipalName;

this.debug = debug;

}

public AppConfigurationEntry[] getAppConfigurationEntry(String name) {

HashMap<String, String> options = new HashMap();

options.put("useKeyTab", "true");

options.put("keyTab", this.keyTabLocation);

options.put("principal", this.servicePrincipalName);

options.put("storeKey", "true");

options.put("doNotPrompt", "true");

if (this.debug) {

options.put("debug", "true");

}

options.put("isInitiator", "false");

return new AppConfigurationEntry[]{

new AppConfigurationEntry(

"com.sun.security.auth.module.Krb5LoginModule",

AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options)

};

}

}

启动服务

启动调试服务的时候,必须增加-Djava.security.krb5.conf=xxxxx,还可以增加如下配置项辅助问题诊断:

-Dsun.security.krb5.debug=true
-Dsun.security.spnego.debug=true

遇到的问题和解决

Failure unspecified at GSS-API level (Mechanism level: Invalid argument (400) - Cannot find key of appropriate type to decrypt AP REP - AES256 CTS mode with HMAC SHA1-96)

keytab指定错误导致

Failure unspecified at GSS-API level (Mechanism level: Request is a replay (34))

同步时钟无果

增加-Dsun.security.krb5.rcache=none解决,参考(https://community.cloudera.com/t5/Support-Questions/Solr-quot-Request-is-a-replay-quot-Ambari-Infra-Solr-2-5/td-p/212870)

Failure unspecified at GSS-API level (Mechanism level: Checksum failed)

跟krb5.conf中配置的加密算法有关,测试下来下面两个方法选其一即可解决,参考(https://stackoverflow.com/questions/26784376/spnego-with-tomcat-error-gssexception-failure-unspecified-at-gss-api-level-me)

[realms]

supported_enctypes = aes256-cts-hmac-sha1-96:special aes128-cts-hmac-sha1-96:special

或者

[libdefaults]

default_tkt_enctypes = arcfour-hmac-md5

总结

本文基于spring的拦截器,实现了一个简化版的SPNEGO协议,可以帮助加深kerberos认证流程和原理的理解,以及加深GSSAPI的原理认识。

javaspringkerberosSPNEGO

阅读 54更新于 今天 03:04

本作品系原创,采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议


全干工程师

分享和记录个人技术经验,由于接触的东西比较杂,可能什么都写,看官见谅

avatar

P_Chou水冗

大数据spark/flink/hadoop/elasticsearch/kafka架构与开发

5k 声望

157 粉丝

0 条评论

得票时间

avatar

P_Chou水冗

大数据spark/flink/hadoop/elasticsearch/kafka架构与开发

5k 声望

157 粉丝

宣传栏

在理清SASL/GSSAPI/Kerberos文中,我们了解到,在java中可以通过Krb5LoginModule模块实现Kerberos登录,以及通过GSSAPI完成校验的基本流程;同时也明白java还定义了一个高层次接口SASL,抽象校验流程。校验流程并没有定义客户端和服务端之间传递“票据”的通信方式,用户可以自己开发合适的通信方案,而SPNEGO协议就是基于http传递票据和校验的标准方案,由微软提出,SPNEGO广泛用于希望集成kerberos认证的http的服务中。本文基于spring interceptor尝试实现一个简易的SPNEGO服务。

准备工作

我们需要准备一些需要的环境和物料。首先我们先回顾一下基于kerberos认证访问服务的整个过程:

【Java】使用Spring拦截器实现SPNEGO服务端

1-2: 客户端首先从KDC中验证得到票据,基于客户端持有的用户名(Principal)和密码(keytab)
3-4: 客户端从KDC上获取要访问的服务(SerivcePrincipal)的票据
5-6: 客户端访问服务(SerivcePrincipal)的时候携带票据,服务端校验票据的合法性

从这个流程可以看到,这是一个保护服务端的流程。也就是说不是谁都能随便访问某服务的,必须是持有合法用户名(Principal)和密码(keytab)的客户端。当然,客户端和服务端的通信还可以再多进一步,即客户端验证服务端的合法性,但这个流程一般是省略的。

从这个流程看,我们需要如下环境和物料:

  • KDC服务器,以及这个服务器的地址信息配置(krb5.conf),用于Krb5LoginModule从KDC处获取票据
  • 一个合法的客户端用户名(Principal)和密码(keytab)
  • 一个合法的服务端SerivcePrincipal,服务端在构建的时候也需要登录SerivcePrincipal,所以SerivcePrincipal对应的keytab也需要

SPNEGO

SPNEGO协议只是在kerberos流程的基础上,将上述的5-6步,通过http的方式定义了。我们可以通过curl来访问基于kerberos认证的http服务,例如:

curl -u : -i -k  --negotiate 'https://192.168.21.134:24148/

其中--negotiate告诉curl支持SPNEGO协议。我们来分析一下curl的流程:

  1. 发送普通http请求
  2. 服务端返回401和WWW-Authentication: Negotiation
  3. curl会初始化gss_context,开始kerberos认证流程:

    • 以当前kinit登录的用户为客户端用户,从kdc处获取票据
    • 以HTTP/[email protected]为SerivcePrincipal,从kdc处获得该服务的访问票据。这里的host是被访问的服务器地址或主机名,DOMAIN为kdc的默认域名。这个规则是curl固定的。所以这就意味两点:1. SerivcePrincipal必须是存在的。2. 服务端也需要在以SerivcePrincipal登录的上下文中验证客户端的票据。
    • 再次发送http请求,生成Authentication: Negotiation xxxxxxx,其中xxxxx为加工以后的base64编码字符串

  4. 服务端验证Authentication: Negotiation xxxxxxx

代码实现

了解了基本原理后,着手开发,spring-security-kerberos这个项目基于spring security框架实现了kerberos认证的服务。受这个项目启发,我们用interceptor实现:

@Override

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

throws Exception {

if (request.getRequestURI().contains("/api/v1/xxl-job")) {

return true;

}

if (!initialized) {

try {

// TODO refreshable

LoginConfig loginConfig = new LoginConfig(

config.getSpnegoAuthConfig().getKeytab(),

config.getSpnegoAuthConfig().getPrincipal(),

Boolean.TRUE.equals(config.getSpnegoAuthConfig().getDebug())

);

Subject sub = new Subject();

loginContext = new LoginContext("", sub, (CallbackHandler)null, loginConfig);

loginContext.login();

} catch (Exception ex) {

logger.error("Failed to initialize GSSAPI context", ex);

loginContext = null;

}

initialized = true;

}

if (loginContext == null) {

// 如果已经初始化过,但是失败了,则放行

return true;

}

String auth = request.getHeader(AUTHORIZATION);

if (auth != null) {

String userName = Subject.doAs(loginContext.getSubject(), new AuthAction(auth.trim()));

if (userName != null) {

logger.debug("Login user by spnego: {}", userName);

return true;

}

}

commence(response);

return false;

}

private void commence(HttpServletResponse response) throws IOException {

response.addHeader("WWW-Authenticate", "Negotiate");

// NOTE: 不能写成sendError,会返回两个WWW-Authenticate: Negotiate。why?

// response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());

response.setStatus(401);

response.flushBuffer();

}

我们知道preHandle会拦截任何一个请求,在这里面我们初始化一次logContext,这里使用的是ServicePrincipal,而且必须是如下形式:

HTTP/<HOST>@<REAL_DOMAIN>

试图获取请求头中的Authorization

String auth = request.getHeader(AUTHORIZATION);

如果存在,就进入验证逻辑,如果不存在或者验证失败,就返回401WWW-Authenticate: Negotiate

以上是代码的基本逻辑,重点是看一下验证逻辑:

public class AuthAction implements PrivilegedExceptionAction<String> {

private String authString;

public AuthAction(String authString) {

this.authString = authString;

}

@Override

public String run() throws Exception {

GSSContext context = GSSManager.getInstance().createContext((GSSCredential)null);

try {

String token = authString.substring("Negotiate".length()).trim();

byte[] kerberosTicket = java.util.Base64.getDecoder().decode(token);

context.acceptSecContext(kerberosTicket, 0, kerberosTicket.length);

return context.getSrcName().toString();

} catch (Exception ex) {

logger.error("Failed to auth token", ex);

return null;

} finally {

context.dispose();

}

}

}

可以清楚的看到,代码是如何解码base64,以及最终调用理清SASL/GSSAPI/Kerberos文中提到的acceptSecContext的。

还有一个技巧,是我们自己实现了LoginConfig类,而不依赖jaas配置文件,因为jdk自带的基于jaas配置文件登录的机制,需要使用System.setProperty配置,会污染全局环境。自定义Configuration类后,可以决定如何将登录的配置信息传递给LoginModule,而不局限于使用全局配置项。受spring-security-kerberos启发,LoginConfig的实现如下:

public class LoginConfig extends Configuration {

private String keyTabLocation;

private String servicePrincipalName;

private boolean debug;

public LoginConfig(String keyTabLocation, String servicePrincipalName, boolean debug) {

this.keyTabLocation = keyTabLocation;

this.servicePrincipalName = servicePrincipalName;

this.debug = debug;

}

public AppConfigurationEntry[] getAppConfigurationEntry(String name) {

HashMap<String, String> options = new HashMap();

options.put("useKeyTab", "true");

options.put("keyTab", this.keyTabLocation);

options.put("principal", this.servicePrincipalName);

options.put("storeKey", "true");

options.put("doNotPrompt", "true");

if (this.debug) {

options.put("debug", "true");

}

options.put("isInitiator", "false");

return new AppConfigurationEntry[]{

new AppConfigurationEntry(

"com.sun.security.auth.module.Krb5LoginModule",

AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options)

};

}

}

启动服务

启动调试服务的时候,必须增加-Djava.security.krb5.conf=xxxxx,还可以增加如下配置项辅助问题诊断:

-Dsun.security.krb5.debug=true
-Dsun.security.spnego.debug=true

遇到的问题和解决

Failure unspecified at GSS-API level (Mechanism level: Invalid argument (400) - Cannot find key of appropriate type to decrypt AP REP - AES256 CTS mode with HMAC SHA1-96)

keytab指定错误导致

Failure unspecified at GSS-API level (Mechanism level: Request is a replay (34))

同步时钟无果

增加-Dsun.security.krb5.rcache=none解决,参考(https://community.cloudera.com/t5/Support-Questions/Solr-quot-Request-is-a-replay-quot-Ambari-Infra-Solr-2-5/td-p/212870)

Failure unspecified at GSS-API level (Mechanism level: Checksum failed)

跟krb5.conf中配置的加密算法有关,测试下来下面两个方法选其一即可解决,参考(https://stackoverflow.com/questions/26784376/spnego-with-tomcat-error-gssexception-failure-unspecified-at-gss-api-level-me)

[realms]

supported_enctypes = aes256-cts-hmac-sha1-96:special aes128-cts-hmac-sha1-96:special

或者

[libdefaults]

default_tkt_enctypes = arcfour-hmac-md5

总结

本文基于spring的拦截器,实现了一个简化版的SPNEGO协议,可以帮助加深kerberos认证流程和原理的理解,以及加深GSSAPI的原理认识。

以上是 【Java】使用Spring拦截器实现SPNEGO服务端 的全部内容, 来源链接: utcz.com/a/114346.html

回到顶部