如何防止浏览器发送 NTLM 凭据?
How to prevent browser from sending NTLM credentials?
我在一个网站上工作,我们想在其中使用 Spring 安全 Kerberos 来使用 Kerberos 身份验证。所以,我们不支持 NTLM。当用户发出未经身份验证的请求时,服务器将使用 HTTP 401 回复 header WWW-Authenticate:协商。
问题:
对于某些 users/configurations,浏览器将发送 NTLM 凭据。服务器不一定是 运行 on Windows,因此它无法处理 NTLM 凭据。
据我了解,“协商”的意思是“如果可能请将 Kerberos 发送给我,否则发送 NTLM”。是否有一个不同的设置说“只向我发送 Kerberos”?或者有什么方法可以告诉浏览器该站点仅支持 Kerberos?
作为 follow-up,为什么浏览器不支持 Kerberos?在这种情况下,他们登录到同一个域。也许他们的凭证已过期?
不要混淆 Kerberos 和 Spnego。尽管 Spnego 通常用于 Kerberos 身份验证,但 Spnego 并不总是意味着 Kerberos,甚至不是对 Kerberos 的偏好。
Spnego 是一种允许客户端和服务器协商相互接受的机械类型(如果可用)的协议。
这可能是也可能不是 Kerberos,具体取决于协商过程中客户端和服务器请求的 sub-mechanisms。
协商过程可能需要多次握手尝试。
以人类语言为例。如果我说英语、拉丁语和祖鲁语,按照优先顺序,你说爱斯基摩语和祖鲁语,那么我们最终会说祖鲁语。
在我目前正在测试的设置中,将 Internet Explorer 作为客户端,并使用 JAAS + GSS 作为服务器的自定义 Java 应用程序服务器,我观察到与您评论中类似的行为:
- 浏览器发送未经身份验证的请求
- 服务器回复 HTTP 401 未授权,WWW-Authenticate:协商 header。
- 浏览器要么响应协商 + NTLM 令牌(错误!)。
在我的例子中,游戏并没有结束,它继续如下:
- 服务器回复 HTTP 401 未授权,WWW-Authenticate:协商 + GSS 响应令牌
- 浏览器响应 Negotiate + Spnego NegoTokenTarg 包装 Kerberos 令牌。
- 服务器解包 Kerberos 令牌;解码并验证客户端;以 HTTP 200 响应,WWW-Authenticate:协商 + GSS 响应令牌
即我不阻止浏览器发送 NTLM 令牌,我的服务器只是继续进行另一轮协商,直到它获得 Kerberos 令牌。
附带问题:Internet Explorer 11 在上述步骤 3. 中提供的令牌不完全符合 Spnego,它既不是 NegTokenInit 也不是 NetTokenTarg,而且 127 字节长显然太短了或包装 Kerberos 令牌。
您正在使用 Spring Security Kerberos,但在评论中您表示对其他库感兴趣,因此下面是我基于 JGSS 的 Spnego 身份验证代码。
为简洁起见,我省略了 JAAS 设置,但所有这些都发生在 JAAS Subject.doAs() 特权上下文中。
public static final String NEGOTIATE = "Negotiate ";
public static final String AUTHORIZATION = "Authorization";
public static final String WWWAUTHENTICATE = "WWW-Authenticate";
public static final int HTTP_OK = 200;
public static final int HTTP_GOAWAY = 401; //Unauthorized
public static final String SPNEGOOID = "1.3.6.1.5.5.2";
public static final String KRB5OID = "1.2.840.113554.1.2.2";
public void spnegoAuthenticate(Request req, Response resp, Service http) {
GSSContext gssContext = null;
String kerberosUser = null;
String auth =req.headers("Authorization");
if ( auth != null && auth.startsWith(NEGOTIATE )) {
//smells like an SPNEGO request, so get the token from the http headers
String authBody = auth.substring(NEGOTIATE.length());
int offset =0;
// As GSS cannot directly process Spnego NegTokenInit and NegTokenTarg, preprocess and extract native Kerberos token.
authBody = preProcessToken(authBody);
try {
byte gssapiData[] = Base64.getDecoder().decode(authBody);
gssContext = initGSSContext(SPNEGOOID, KRB5OID);
byte token[] = gssContext.acceptSecContext(gssapiData, offset, gssapiData.length);
if (gssapiData.length > 128) {
//extract the Kerberos User. The Execute/Login service will compare this with the user in the message body.
kerberosUser = gssContext.getSrcName().toString();
resp.status(HTTP_OK);
} else {
//Is too short to be a kerberos token (or to wrap one), so don't try and extract the user.
//This could be a first pass from an SPNEGO enabled Web-browser. Maybe NTLM?
resp.status(HTTP_GOAWAY);
}
String responseToken = Base64.getEncoder().encodeToString(token);
if (responseToken != null && responseToken.length() > 0) {
resp.header(WWWAUTHENTICATE, NEGOTIATE + responseToken);
}
} catch (GSSException e) {
// Something went wrong fishing the token from the http headers
http.halt(401, "Go Away! This is a privileged route, and you ain't privileged!"+"\r\n");
} finally {
try {
gssContext.dispose();
} catch (GSSException e) {
//error handling here
}
}
} else {
//This is either not a SPNEGO request, or is the first pass without token
resp.header(WWWAUTHENTICATE, NEGOTIATE.trim()); //set header to suggest negotiation
http.halt(HTTP_GOAWAY, "Go Away! This is a privileged route, and you ain't privileged! Only come back when you are."+"\r\n");
}
}
private String preProcessToken(String authBody) {
String tag = getTokenType(authBody);
if (tag.equals("60")) {
// is a standard "application constructed" token. Kerberos tokens seem to start with "YI.."
} else if (tag.equals("A0")) {
// is a Spnego NegTokenInit, starting with "oA.." to "oP.."
authBody=extractKerberosToken(authBody);
} else if (tag.equals("A1")) {
// is a Spnego NegTokenTarg, starting with "oQ.." to "oZ.."
authBody=extractKerberosToken(authBody);
} else {
// some other unexpected token.
// TODO: generate error
}
return authBody;
}
private String extractKerberosToken(String authBody) {
return authBody.substring(authBody.indexOf("YI", 2));
}
private String getTokenType(String authBody) {
return String.format("%02X", Base64.getDecoder().decode(authBody.substring(0,2))[0]);
}
请注意此代码 "as-is" 是作为示例提供的。它是 work-in-progress 并且有一些缺陷:
1) getTokenType() 使用解码后的令牌,但 extractKerberosToken 作用于编码后的令牌,两者都应该对解码后的令牌使用字节操作。
2) 基于长度的令牌拒绝有点太简单了。我计划添加更好的 NTLM 令牌标识....
3) 我没有真正的 GSS 上下文循环。如果我不喜欢客户呈现的内容,我会拒绝并关闭上下文。
对于客户端的任何后续握手尝试,我都会打开一个新的 GSS 上下文。
我在一个网站上工作,我们想在其中使用 Spring 安全 Kerberos 来使用 Kerberos 身份验证。所以,我们不支持 NTLM。当用户发出未经身份验证的请求时,服务器将使用 HTTP 401 回复 header WWW-Authenticate:协商。
问题: 对于某些 users/configurations,浏览器将发送 NTLM 凭据。服务器不一定是 运行 on Windows,因此它无法处理 NTLM 凭据。
据我了解,“协商”的意思是“如果可能请将 Kerberos 发送给我,否则发送 NTLM”。是否有一个不同的设置说“只向我发送 Kerberos”?或者有什么方法可以告诉浏览器该站点仅支持 Kerberos?
作为 follow-up,为什么浏览器不支持 Kerberos?在这种情况下,他们登录到同一个域。也许他们的凭证已过期?
不要混淆 Kerberos 和 Spnego。尽管 Spnego 通常用于 Kerberos 身份验证,但 Spnego 并不总是意味着 Kerberos,甚至不是对 Kerberos 的偏好。
Spnego 是一种允许客户端和服务器协商相互接受的机械类型(如果可用)的协议。
这可能是也可能不是 Kerberos,具体取决于协商过程中客户端和服务器请求的 sub-mechanisms。 协商过程可能需要多次握手尝试。
以人类语言为例。如果我说英语、拉丁语和祖鲁语,按照优先顺序,你说爱斯基摩语和祖鲁语,那么我们最终会说祖鲁语。
在我目前正在测试的设置中,将 Internet Explorer 作为客户端,并使用 JAAS + GSS 作为服务器的自定义 Java 应用程序服务器,我观察到与您评论中类似的行为:
- 浏览器发送未经身份验证的请求
- 服务器回复 HTTP 401 未授权,WWW-Authenticate:协商 header。
- 浏览器要么响应协商 + NTLM 令牌(错误!)。
在我的例子中,游戏并没有结束,它继续如下:
- 服务器回复 HTTP 401 未授权,WWW-Authenticate:协商 + GSS 响应令牌
- 浏览器响应 Negotiate + Spnego NegoTokenTarg 包装 Kerberos 令牌。
- 服务器解包 Kerberos 令牌;解码并验证客户端;以 HTTP 200 响应,WWW-Authenticate:协商 + GSS 响应令牌
即我不阻止浏览器发送 NTLM 令牌,我的服务器只是继续进行另一轮协商,直到它获得 Kerberos 令牌。
附带问题:Internet Explorer 11 在上述步骤 3. 中提供的令牌不完全符合 Spnego,它既不是 NegTokenInit 也不是 NetTokenTarg,而且 127 字节长显然太短了或包装 Kerberos 令牌。
您正在使用 Spring Security Kerberos,但在评论中您表示对其他库感兴趣,因此下面是我基于 JGSS 的 Spnego 身份验证代码。
为简洁起见,我省略了 JAAS 设置,但所有这些都发生在 JAAS Subject.doAs() 特权上下文中。
public static final String NEGOTIATE = "Negotiate ";
public static final String AUTHORIZATION = "Authorization";
public static final String WWWAUTHENTICATE = "WWW-Authenticate";
public static final int HTTP_OK = 200;
public static final int HTTP_GOAWAY = 401; //Unauthorized
public static final String SPNEGOOID = "1.3.6.1.5.5.2";
public static final String KRB5OID = "1.2.840.113554.1.2.2";
public void spnegoAuthenticate(Request req, Response resp, Service http) {
GSSContext gssContext = null;
String kerberosUser = null;
String auth =req.headers("Authorization");
if ( auth != null && auth.startsWith(NEGOTIATE )) {
//smells like an SPNEGO request, so get the token from the http headers
String authBody = auth.substring(NEGOTIATE.length());
int offset =0;
// As GSS cannot directly process Spnego NegTokenInit and NegTokenTarg, preprocess and extract native Kerberos token.
authBody = preProcessToken(authBody);
try {
byte gssapiData[] = Base64.getDecoder().decode(authBody);
gssContext = initGSSContext(SPNEGOOID, KRB5OID);
byte token[] = gssContext.acceptSecContext(gssapiData, offset, gssapiData.length);
if (gssapiData.length > 128) {
//extract the Kerberos User. The Execute/Login service will compare this with the user in the message body.
kerberosUser = gssContext.getSrcName().toString();
resp.status(HTTP_OK);
} else {
//Is too short to be a kerberos token (or to wrap one), so don't try and extract the user.
//This could be a first pass from an SPNEGO enabled Web-browser. Maybe NTLM?
resp.status(HTTP_GOAWAY);
}
String responseToken = Base64.getEncoder().encodeToString(token);
if (responseToken != null && responseToken.length() > 0) {
resp.header(WWWAUTHENTICATE, NEGOTIATE + responseToken);
}
} catch (GSSException e) {
// Something went wrong fishing the token from the http headers
http.halt(401, "Go Away! This is a privileged route, and you ain't privileged!"+"\r\n");
} finally {
try {
gssContext.dispose();
} catch (GSSException e) {
//error handling here
}
}
} else {
//This is either not a SPNEGO request, or is the first pass without token
resp.header(WWWAUTHENTICATE, NEGOTIATE.trim()); //set header to suggest negotiation
http.halt(HTTP_GOAWAY, "Go Away! This is a privileged route, and you ain't privileged! Only come back when you are."+"\r\n");
}
}
private String preProcessToken(String authBody) {
String tag = getTokenType(authBody);
if (tag.equals("60")) {
// is a standard "application constructed" token. Kerberos tokens seem to start with "YI.."
} else if (tag.equals("A0")) {
// is a Spnego NegTokenInit, starting with "oA.." to "oP.."
authBody=extractKerberosToken(authBody);
} else if (tag.equals("A1")) {
// is a Spnego NegTokenTarg, starting with "oQ.." to "oZ.."
authBody=extractKerberosToken(authBody);
} else {
// some other unexpected token.
// TODO: generate error
}
return authBody;
}
private String extractKerberosToken(String authBody) {
return authBody.substring(authBody.indexOf("YI", 2));
}
private String getTokenType(String authBody) {
return String.format("%02X", Base64.getDecoder().decode(authBody.substring(0,2))[0]);
}
请注意此代码 "as-is" 是作为示例提供的。它是 work-in-progress 并且有一些缺陷:
1) getTokenType() 使用解码后的令牌,但 extractKerberosToken 作用于编码后的令牌,两者都应该对解码后的令牌使用字节操作。
2) 基于长度的令牌拒绝有点太简单了。我计划添加更好的 NTLM 令牌标识....
3) 我没有真正的 GSS 上下文循环。如果我不喜欢客户呈现的内容,我会拒绝并关闭上下文。 对于客户端的任何后续握手尝试,我都会打开一个新的 GSS 上下文。