主要讲什么?
本篇主要讲在 HTTP 头上的认证过程, 而非在传统的表单上认证
介绍了两种 HTTP 认证方法
- HTTP Basic authentication
- HTTP Digest authentication
HTTP Basic authentication
是什么?
HTTP Basic authentication中文译作HTTP基本认证,在这种认证方式中,将用户的登录用户名/密码经过Base64编码之后,放在请求头的Authorization字段中,从而完成用户身份的认证。
客户端: 我要拿到员工列表信息(GET /list)
服务端: 你没认证, 先去认证, 给你挑选一种认证的方式(WWW-Authenticate: Basic realm: "Realm")
客户端: 好的, 我将用户名和密码整合在一起, 使用Base64加密, 将密码发送给你(Authorization: Basic XXXXX=)
服务端: 收到你的密码了, 我开始验证你的账号和密码, 验证通过, 给你员工信息列表
客户端: 谢谢
使用场景是什么?
这种方式不安全, 很少被使用, 一般有用也是在内网系统中, 默认客户端和服务端所处环境很安全的情况下, 使用这种方式认证
同时这种方式无法退出登录, 除非用户清除浏览器缓存或者关闭浏览器
实战
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.httpBasic() // 开始 Http basic authentication
.and().csrf().disable();
}
}
server:
port: 8080
spring:
security:
user:
name: zhazha
password: "{noop}123456"
源码分析
进入这个方法看到下面的方法
意味客户端的每个请求都会触发该过滤器
找了下 doFilter 方法在OncePerRequestFilter类中, 我们可以看看
@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 代码被省略
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
doFilterInternal(httpRequest, httpResponse, filterChain);
}
}
核心代码:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
if (authRequest == null) {
this.logger.trace("Did not process authentication request since failed to find "
+ "username and password in Basic Authorization header");
chain.doFilter(request, response);
return;
}
String username = authRequest.getName();
this.logger.trace(LogMessage.format("Found username '%s' in Basic Authorization header", username));
if (authenticationIsRequired(username)) {
Authentication authResult = this.authenticationManager.authenticate(authRequest);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
this.securityContextRepository.saveContext(context, request, response);
onSuccessfulAuthentication(request, response, authResult);
}
}
catch (AuthenticationException ex) {
SecurityContextHolder.clearContext();
this.logger.debug("Failed to process authentication request", ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
if (this.ignoreFailure) {
chain.doFilter(request, response);
}
else {
this.authenticationEntryPoint.commence(request, response, ex);
}
return;
}
chain.doFilter(request, response);
}
直接走流程图分析:
HTTP Digest authentication
是什么?
为弥补基本认证的缺点,从HTTP/1.1就有了摘要认证。摘要认证利用摘要算法(如MD5、SHA-256)对用户名、密码、报文内容等做不可逆的编码,来防止重要信息被获取、篡改。
和HTTP Basic认证方式基本一致, 只不过传递的参数存在差异而已
流程
-
步骤 1: 请求需认证的资源时,服务器会随着状态码
401 Authorization Required,返回带WWW-Authenticate首部字段的响应。该字段内包含质问响应方式认证所需的临时质询码(随机数nonce)。首部字段WWW-Authenticate内必须包含realm和nonce这两个字段的信息。客户端就是依靠向服务器回送这两个值进行认证的。nonce是一种每次随返回的401响应生成的任意随机字符串。该字符串通常推荐由Base64编码的十六进制数的组成形式,但实际内容依赖服务器的具体实现。 -
步骤 2:接收到
401状态码的客户端,返回的响应中包含DIGEST认证必须的首部字段Authorization信息。首部字段Authorization内必须包含username、realm、nonce、uri和response的字段信息。其中,realm和nonce就是之前从服务器接收到的响应中的字段。username是realm限定范围内可进行认证的用户名。uri(digest-uri)即Request-URI的值,但考虑到经代理转发后Request-URI的值可能被修改因此事先会复制一份副本保存在uri内。
response 也可叫做 Request-Digest,存放经过 MD5 运算后的密码字符串,形成响应码。
- 步骤 3:接收到包含首部字段
Authorization请求的服务器,会确认认证信息的正确性。认证通过后则返回包含Request-URI资源的响应。并且这时会在首部字段Authentication-Info写入一些认证成功的相关信息。(不过我下面的例子没有去写这个Authentication-Info,而是直接返回的数据。因为我实在session里缓存的认证结果)。
username: 用户名。
password: 用户密码。
realm: 认证域,由服务器返回。
opaque: 透传字符串,客户端应原样返回。
method: 请求的方法。
nonce: 由服务器生成的随机字符串,包含过期时间(默认过期时间300s)和密钥。
nc: 即nonce-count,指请求的次数,用于计数,防止重放攻击。qop被指定时,nc也必须被指定。
cnonce: 客户端发给服务器的随机字符串,qop被指定时,cnonce也必须被指定。
qop: 保护级别,客户端根据此参数指定摘要算法。若取值为 auth,则只进行身份验证;若取值为auth-int,则还需要校验内容完整性,默认的qop为auth。
uri: 请求的uri。
response: 客户端根据算法算出的摘要值,这个算法取决于qop。
algorithm: 摘要算法,目前仅支持MD5。
entity-body: 页面实体,非消息实体,仅在auth-int中支持。
实战
Spring Security中为HTTP摘要认证提供了相应的AuthenticationEntryPoint和 Filter,但是没有自动化配置,需要我们手动配置,配置方式如下:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.DigestAuthenticationFilter;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and().csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(digestAuthenticationEntryPoint()) // 添加质询方式
.and().addFilter(digestAuthenticationFilter()); // 添加核心过滤器
}
/**
* http digest 认证核心过滤器
*
* @return
*/
private DigestAuthenticationFilter digestAuthenticationFilter() {
DigestAuthenticationFilter filter = new DigestAuthenticationFilter();
filter.setPasswordAlreadyEncoded(true);
filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());
filter.setUserDetailsService(userDetailsServiceBean());
return filter;
}
@Bean
@Override
public UserDetailsService userDetailsServiceBean() {
InMemoryUserDetailsManager userDetailsService = new InMemoryUserDetailsManager();
userDetailsService.createUser(User.withUsername("zhazha")
.password("afcfa5bad0d8e350a7c18eee9f691d1c")
.roles("admin")
.build());
return userDetailsService;
}
/**
* 提供了摘要的质询功能 commence
*
* @return
*/
private DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {
DigestAuthenticationEntryPoint entryPoint = new DigestAuthenticationEntryPoint();
entryPoint.setNonceValiditySeconds(3600);
entryPoint.setRealmName("myrealm");
entryPoint.setKey("zhazha");
return entryPoint;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
加密密码的方式是:
@Test
public void testMd5() throws Exception {
String rawPassword = "zhazha:myrealm:123456";
MessageDigest digest = MessageDigest.getInstance("MD5");
String s = new String(Hex.encode(digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8))));
System.err.println(s);
}
username + ":" + realm + ":" + password
源码分析
质询
质询核心代码
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 根据当前时间 + 阈值 算出未来的某个时间 当作过期时间
long expiryTime = System.currentTimeMillis() + (this.nonceValiditySeconds * 1000);
// 签名: 过期时间 + : + key (这个key我们在前面配置过了 entryPoint.setKey("zhazha"))
String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + this.key);
// nonce未加密的值 = 过期时间 + : + 签名
String nonceValue = expiryTime + ":" + signatureValue;
// Base64加密 nonceValue
// nonce = base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
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\"";
}
logger.debug(LogMessage.format("WWW-Authenticate header sent to user agent: %s", authenticateHeader));
// 将得到的值存放到 WWW-Authenticate 中
response.addHeader("WWW-Authenticate", authenticateHeader);
// 响应未认证代码 401 和 未认证字符串 Unauthorized
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}
核心代码
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 从 header 中取出 Authorization 也就是上面图片的字符串
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Digest ")) {
chain.doFilter(request, response);
return;
}
// 解析出 header 中的配置
DigestData digestAuth = new DigestData(header);
try {
// 各种验证
digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(),
this.authenticationEntryPoint.getRealmName());
}
catch (BadCredentialsException ex) {
fail(request, response, ex);
return;
}
// 认证过程
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(digestAuth.getUsername());
String serverDigestMd5;
try {
if (user == null) {
cacheWasUsed = false;
user = this.userDetailsService.loadUserByUsername(digestAuth.getUsername());
if (user == null) {
throw new AuthenticationServiceException(
"AuthenticationDao returned null, which is an interface contract violation");
}
this.userCache.putUserInCache(user);
}
// 两个密码使用 md5 验证
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// If digest is incorrect, try refreshing from backend and recomputing
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
user = this.userDetailsService.loadUserByUsername(digestAuth.getUsername());
this.userCache.putUserInCache(user);
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
}
}
catch (UsernameNotFoundException ex) {
String message = this.messages.getMessage("DigestAuthenticationFilter.usernameNotFound",
new Object[] { digestAuth.getUsername() }, "Username {0} not found");
fail(request, response, new BadCredentialsException(message));
return;
}
// If digest is still incorrect, definitely reject authentication attempt
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
logger.debug(LogMessage.format(
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
serverDigestMd5, digestAuth.getResponse()));
String message = this.messages.getMessage("DigestAuthenticationFilter.incorrectResponse",
"Incorrect response");
fail(request, response, new BadCredentialsException(message));
return;
}
// 判断过期了没?
if (digestAuth.isNonceExpired()) {
String message = this.messages.getMessage("DigestAuthenticationFilter.nonceExpired",
"Nonce has expired/timed out");
fail(request, response, new NonceExpiredException(message));
return;
}
logger.debug(LogMessage.format("Authentication success for user: '%s' with response: '%s'",
digestAuth.getUsername(), digestAuth.getResponse()));
// 认证成功, 存储到 SecurityContextHolder 中
Authentication authentication = createSuccessfulAuthentication(request, user);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
chain.doFilter(request, response);
}
nonce和qop:就是服务端返回的数据。nc:表示请求次数,该参数在防止重放攻击时有用。cnonce:表示客户端生成的随机数。