HTTP摘要认证与HTTP基本认证一样,都是基于HTTP层面的认证方式,不使用session,因而不支持Remember-me。虽然解决了HTTP基本认证密码明文传输的问题,但并未解决密码明文存储的问题,依然存在安全隐患。HTTP摘要认证与HTTP基本认证相比,仅仅在非加密的传输层中有安全优势,但是其相对复杂的实现流程,使得它并不能成为一种被广泛使用的认证方式。
概念
摘要认证(Digest Authentication)是一个简单的认证机制,最初是为HTTP协议开发的,因而也常叫HTTP摘要,在RFC2617中描述,其身份验证机制也很简单,采用散列式(Hash)加密算法,以避免明文传输用户的密码口令。
HTTP摘要认证中涉及的一些参数介绍
- username:用户名
- password:密码口令
- realm:认证域,由服务端返回
- opaque:透传字符串,客户端原样返回
- method:请求的方法,大写
- nonce:由服务器生成的随机字符串
- nc:即nonce-count,指请求的次数,用于计数,防止重放攻击。qop被指定时,nc也必须被指定
- cnonce:客户端发给服务器的随机字符串,qop被指定时,cnonce也必须被指定
- qop:保护级别,客户端根据此参数指定摘要算法。若取值为auth,则只进行身份验证;若取值为auth-int,则还需要验证内容完整性
- uri:请求uri
- response:客户端根据算法算出的摘要值
- algorithm:摘要算法,目前只支持MD5
- entity-body:页面实体,非消息实体,仅在auth-int中支持
认证步骤
Spring Security对HTTP摘要认证的集成支持
对于服务端最重要的字段是nonce;对于客户端而言,最重要的字段是response。验证的大致流程是:客户端首先按照约定的算法计算并发送response;服务端接收之后,以同样的方式计算得到一个response,如果两个response相同,则证明摘要正确。最后用Base64解码原来nonce得到过期时间,以验证该摘要是否还有效。需要注意的是,由于HTTP摘要认证必须读取用户的明文密码,所以不应该在SpringSecurity中使用任何密码加密方式。
nonce是由服务端生成的随机字符串,包括过期时间和密钥。
1 2 3 4 5
| SpringSecurity中生成算法如下:
base64(expirationTime + ":" + md5(expirationTime + ":" + key))
其中,expirationTime默认为300s,在DigestAuthenticatonEntryPoint中可以找到SpringSecurity发送“质询”数据的过程。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { long expiryTime = System.currentTimeMillis() + (long)(this.nonceValiditySeconds * 1000); String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + this.key); String nonceValue = expiryTime + ":" + signatureValue; String nonceValueBase64 = new String(Base64.getEncoder().encode(nonceValue.getBytes())); String authenticateHeader = "Digest realm=\"" + this.realmName + "\", qop=\"auth\", nonce=\"" + nonceValueBase64 + "\""; if (authException instanceof NonceExpiredException) { authenticateHeader = authenticateHeader + ", stale=\"true\""; }
if (logger.isDebugEnabled()) { logger.debug("WWW-Authenticate header sent to user agent: " + authenticateHeader); }
response.addHeader("WWW-Authenticate", authenticateHeader); response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); }
|
SpringSecurity默认实现了qop为auth的摘要认证模式。如果在客户端最后发起的“响应”中,摘要有效但已过期,那么SpringSecurity会重新发回一个“质询”,并增加stale=true字段告诉客户端不需要重新弹出验证框,用户名和密码是正确的,只需使用新的nonce尝试即可。
reponse是客户端最重要的字段,它是整个验证能否通过的关键,它的算法取决于qop,如果qop未指定,则它的算法是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| A1 = md5(username:realm:password) A2 = md5(method:uri) response = md5(A1:nonce:A2) 如果qop指定为auth,则它的算法是: A1 = md5(username:realm:password) A2 = md5(method:uri) response = md5(A1:nonce:nc:cnonce:qop:A2) SpringSecurity中实现的代码如下:
static String generateDigest(boolean passwordAlreadyEncoded, String username, String realm, String password, String httpMethod, String uri, String qop, String nonce, String nc, String cnonce) throws IllegalArgumentException { String a2 = httpMethod + ":" + uri; String a2Md5 = md5Hex(a2); String a1Md5; if (passwordAlreadyEncoded) { a1Md5 = password; } else { a1Md5 = encodePasswordInA1Format(username, realm, password); }
String digest; if (qop == null) { digest = a1Md5 + ":" + nonce + ":" + a2Md5; } else { if (!"auth".equals(qop)) { throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'"); }
digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5; }
return md5Hex(digest); }
|
例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| Spring Boot Security实现HTTP摘要认证的简单例子: @EnableWebSecurity @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .antMatcher("/rest/**") .addFilter(digestAuthenticationFilter()) .exceptionHandling() .authenticationEntryPoint(digestEntryPoint()) .and().authorizeRequests() .antMatchers("/rest/hello1").hasRole("USER") .anyRequest().authenticated();
}
public DigestAuthenticationFilter digestAuthenticationFilter() throws Exception { DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter(); digestAuthenticationFilter.setUserDetailsService(userDetailsServiceBean()); digestAuthenticationFilter.setAuthenticationEntryPoint(digestEntryPoint()); return digestAuthenticationFilter; }
public DigestAuthenticationEntryPoint digestEntryPoint() { DigestAuthenticationEntryPoint digestAuthenticationEntryPoint = new DigestAuthenticationEntryPoint(); digestAuthenticationEntryPoint.setKey("spring security"); digestAuthenticationEntryPoint.setRealmName("realm"); digestAuthenticationEntryPoint.setNonceValiditySeconds(500); return digestAuthenticationEntryPoint; }
@Bean public UserDetailsService userDetailsServiceBean() throws Exception {
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager(); PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); inMemoryUserDetailsManager.createUser(User.withUsername("user") .passwordEncoder(s -> s + "1") .password("password").roles("USER").build()); String bcryptPassword = encoder.encode("password"); System.out.println(bcryptPassword); inMemoryUserDetailsManager.createUser(User.withUsername("admin") .passwordEncoder(s -> s + "2") .password("password").roles("ADMIN", "USER").build()); return inMemoryUserDetailsManager;
}
@Bean public PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); }
public PasswordEncoder getPasswordEncoder() { return new PasswordEncoder() { @Override public String encode(CharSequence charSequence) { return charSequence.toString(); }
@Override public boolean matches(CharSequence charSequence, String s) { return true; } }; } }
|
通过实际的请求交互,可以加深对整个流程的理解。在浏览器弹出的认证框中,单击"取消"按钮,可以看到该请求的“质询"内容如下:
将服务端"质询”的参数realm、nonce等填入到PostMan中,再一次请求,就能获取需要的内容。
在浏览器弹出的认证框中填入正确的账号和密码之后,请求响应的内容如下:
对上图中的Authorization中的相应参数通过MD5在线加密工具(32位小写)可以计算出response参数是一模一样的
1 2 3
| MD5(admin:realm:password2) ==> b439e2c3c81cc87bab6d66bdf9ec7f78 MD5(GET:/rest/hello1) ==> adff0b266ac8e9a6f95cf572f81bb8c4 MD5(b439e2c3c81cc87bab6d66bdf9ec7f78:MTU4MDg4NTYwOTgwMzpmNmQwZTkyMGE2ZTkwN2Y2OGZjZDFlOTg0MWEwMDhkYg==:00000002:7f4a8b7436c03ead:auth:adff0b266ac8e9a6f95cf572f81bb8c4) ==> 5cf1fc9c818648ef51c14bb85e3a57c4
|
除了浏览器自动实现该算法之外,XMLHttpRequest也有支持
XMLHttpRequest.open(method, url, async, username, password)
可以在浏览器控制台上做实验,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| request = new XMLHttpRequest() request.onreadystatechange = function() { if (request.readyState == 4 && request.status == 200) { console.log('get response: ', request.response)
} } request.open( "GET", "http://localhost:8080/rest/hello1", true, "user", "password1" ) request.send()
|
如果直接使用上述代码会出现跨域问题,所以需要将"chrome-search://local-ntp"加入到允许的域列表中。
跨域配合代码如下:
1 2 3 4 5 6 7 8 9 10 11
| @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "chrome-search://local-ntp")); configuration.setAllowedMethods(Arrays.asList("GET","POST")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setExposedHeaders(Arrays.asList("Authorization", "WWW-Authenticate")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }
|
本文测试代码
SpringBoot-Digest-Authentiation
关注【憨才好运】微信公众号,了解更多精彩内容⬇️⬇️⬇️