Spring Security的HTTP认证

623 阅读7分钟

主要讲什么?

本篇主要讲在 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"

image-20221129151222982

源码分析

image-20221129151318369

进入这个方法看到下面的方法

image-20221129151348633

image-20221129151618981

image-20221129151716826

image-20221129152531843

意味客户端的每个请求都会触发该过滤器

找了下 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);
}

直接走流程图分析:

HttpBasic认证过程

HTTP Digest authentication

是什么?

为弥补基本认证的缺点,从HTTP/1.1就有了摘要认证。摘要认证利用摘要算法(如MD5、SHA-256)对用户名、密码、报文内容等做不可逆的编码,来防止重要信息被获取、篡改。

和HTTP Basic认证方式基本一致, 只不过传递的参数存在差异而已

流程

img

  • 步骤 1: 请求需认证的资源时,服务器会随着状态码 401 Authorization Required,返回带WWW-Authenticate 首部字段的响应。该字段内包含质问响应方式认证所需的临时质询码(随机数nonce)。首部字段 WWW-Authenticate 内必须包含realmnonce 这两个字段的信息。客户端就是依靠向服务器回送这两个值进行认证的。nonce 是一种每次随返回的 401 响应生成的任意随机字符串。该字符串通常推荐由Base64 编码的十六进制数的组成形式,但实际内容依赖服务器的具体实现。

  • 步骤 2:接收到401状态码的客户端,返回的响应中包含 DIGEST 认证必须的首部字段 Authorization 信息。首部字段 Authorization 内必须包含 usernamerealmnonceuriresponse的字段信息。其中,realmnonce 就是之前从服务器接收到的响应中的字段。 usernamerealm 限定范围内可进行认证的用户名。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摘要认证提供了相应的AuthenticationEntryPointFilter,但是没有自动化配置,需要我们手动配置,配置方式如下:

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());
}

image-20221129175707742

核心代码

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);
}
  • nonceqop:就是服务端返回的数据。
  • nc:表示请求次数,该参数在防止重放攻击时有用。
  • cnonce:表示客户端生成的随机数。