HTTP认证/授权方案(二):Digest Access

Posted by Lucky Xue on 2020-02-05

​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") // 如果是第二种写法,这里是hasAuthority
.anyRequest().authenticated();
// .antMatchers("/rest/hello2").hasRole("ADMIN");
}

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 {
// // TODO Auto-generated method stub
// return super.userDetailsServiceBean();
// }

@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;

// 第二种写法
// List<GrantedAuthority> authorities = new ArrayList<>();
// authorities.add(new SimpleGrantedAuthority("USER"));
// authorities.add(new SimpleGrantedAuthority("ADMIN"));
// PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// System.out.println(passwordEncoder().encode("password"));
// return username -> new User("admin", passwordEncoder().encode("password"), true,
// true, true, true, authorities);
}

// 第三种写法
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// auth.inMemoryAuthentication().passwordEncoder(getPasswordEncoder())
// .withUser("user").password("password1").roles("USER")
// .and().withUser("admin").password("password2").roles("ADMIN", "USER");
// }

@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;
}
};
}
}

通过实际的请求交互,可以加深对整个流程的理解。在浏览器弹出的认证框中,单击"取消"按钮,可以看到该请求的“质询"内容如下:

continuous_deployment

将服务端"质询”的参数realm、nonce等填入到PostMan中,再一次请求,就能获取需要的内容。

continuous_deployment

在浏览器弹出的认证框中填入正确的账号和密码之后,请求响应的内容如下:

continuous_deployment

对上图中的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"加入到允许的域列表中。

continuous_deployment

跨域配合代码如下:

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

关注【憨才好运】微信公众号,了解更多精彩内容⬇️⬇️⬇️

continuous_deployment